纯JAVA实现Online Judge--4.限时运行(杀死线程)

来源:互联网 发布:爱新觉罗知乎 编辑:程序博客网 时间:2024/05/24 03:21

 前言

    在上一篇的博客中,我们通过设置SecurityManager已经实现了大部分的安全措施。这里我们将实现最后的安全措施,防止用户提交死循环的代码无止境的消耗服务器的CPU,以及防止用户恶意破坏沙箱运行代码的能力。同时,因为OJ业务要求的限制,每一道题目的答案代码运行都应该是限时的(比如限时1000毫秒内出结果),对于运行超过指定时间还未出结果的,我们就应该终止运行的线程,并判定这个测试用例这份代码超时了。

无法中断的线程

    我们应该都知道JAVA的线程是协作式而非抢占式的。也就是说对于运行已经超时的线程,或者因为死循环而一直在工作的线程,我们是无法使用JAVA自带的中断API(如Thread类的interrupt方法)进行有效停止的,线程还会依旧运行下去。由于我们无法限制用户的算法,而有些算法可能本身因为出现了漏洞,导致了死循环的出现。总而言之,对于我们通过反射调用main方法运行起来的用户代码,一旦死循环了或者超时了,我们是无法通过JAVA推荐的中断的方式,去终止运行用户代码的线程。

废弃的stop方法

    要解决上述问题,需要用到一个JAVA已经废弃的方法:Thread类的stop方法。使用这个方法就可以强制杀掉一个超时运行的线程。JAVA要废弃stop方法的最大一个原因是因为它是不安全的。为什么说不安全呢?大致就是因为:用 Thread.stop 来终止线程将释放它已经锁定的所有监视器(作为沿堆栈向上传播的未检查 ThreadDeath 异常的一个自然后果)。如果以前受这些监视器保护的任何对象都处于一种不一致的状态,则损坏的对象将对其他线程可见,这有可能导致任意的行为。

    用人话来讲,强行使用stop方法后,将可能导致我们的程序产生不一致性。这还是很好理解的,比如你做事情做到一半,就被别人强行叫停了,本身就很有问题。举一个不太适当例子就是,比如A线程和B线程共用一把锁,A线程先获得锁然后做某些业务操作,B线程在等待A线程释放锁并根据A线程操作的结果,做出相应的操作,这个时候就会出事了。

    例子代码如下:(仅仅是为了说明效果,用synchronized关键字也行,并且synchronized会自己释放锁

import java.util.concurrent.locks.ReentrantLock;public class Main {volatile static int aa = 1;volatile static boolean isHaveAdd = false;volatile static int cc = 1;volatile static ReentrantLock lock = new ReentrantLock();public static void main(String[] args) throws InterruptedException {Thread thread = new Thread() {public void run() {System.out.println("第一个线程在等待锁");try {lock.lock();System.out.println("第一个线程获得锁");aa++;// 模拟一个超级耗时而且无法中断的操作for (long i = 0; i < Long.MAX_VALUE; i++) {// 假设有这么一个业务需求,当耗时操作快完成时,需要isHaveAdd要变为true告诉别人aa被加过了if (i == Long.MAX_VALUE - 1) {isHaveAdd = true;}}} catch (Exception e) {e.printStackTrace();} finally {System.out.println("进入finally方法");// 需要注意的是,跟synchronized不同的是,这里我们需要自己手动释放锁,synchronized会自己释放锁lock.unlock();}};};thread.start();// 确保上面的线程先运行Thread.sleep(10);new Thread() {public void run() {System.out.println("第二个线程在等待锁");try {lock.lock();System.out.println("第二个线程获得锁");System.out.println(aa);System.out.println(isHaveAdd);System.out.println(cc);if (isHaveAdd) {cc++;}System.out.println(aa);System.out.println(isHaveAdd);System.out.println(cc);} catch (Exception e) {e.printStackTrace();} finally {lock.unlock();}};}.start();// 稍微等待一下,然后杀死线程Thread.sleep(500);System.out.println("杀死线程");thread.stop();}}
运行结果将会是:

第一个线程在等待锁
第一个线程获得锁
第二个线程在等待锁
杀死线程
进入finally方法
第二个线程获得锁
2
false
1
2
false
1

可以看出,因为线程A的逻辑还未执行完,导致aa虽然加1了,但是cc并未能加1,业务出现了一定的问题。再一次说明,我这个例子是不太恰当的,但是为了说明问题,我也一时想不出一个简单的代码例子,所以就只好这样了。感兴趣朋友,可以将ReentrantLock这种锁的方式换回synchronized的方式,再试试效果。

使用stop方法

    关于stop方法等内容,我这里不继续探讨了,因为本文并不是主要讲述这个的。虽然上面说了那么多,但是因为业务需求,我们最终还是使用了stop方法。而且,因为OJ业务的特殊性,stop方法的危害对于我们来说,基本上都不是危害。

    为什么这么说呢?因为当代码运行超时时,其实我们已经得出了该代码对于这份测试用例的结果了,再由于我们的代码不会跟用户提交的代码涉及到任何相关的监视器、锁。因此,放心大胆的使用stop方法吧!

    下面给出,在本系统沙箱端中,调用杀死线程的主要函数(需要主要的是,因为我们采用了future模式,因此需要用到反射,拿到真正运行改任务的工作线程,顺便说明的是submit.cancel(true)其实也是利用中断机制的,面对死循环等情况,也是无力的,这里使用,仅仅是因为~我就是想调用一下而已0.0):

@SuppressWarnings("deprecation")private void killThread(FutureTask<ProblemResultItem> submit) {try {submit.cancel(true);// 利用反射,强行取出正在运行该任务的线程Field runner = submit.getClass().getDeclaredField("runner");runner.setAccessible(true);Thread execThread = (Thread) runner.get(submit);execThread.stop();submit.cancel(true);} catch (Exception e) {System.err.println(e);}}

    为了让代码看起来稍微完整一点,我下面再贴出对于一份用户代码,运行对应题目的所有测试数据时的任务类:ProblemCallable,如果留意代码的话,还会发现一个很相近的类:ProblemItemCallable。ProblemItemCallable是运行每一个测试用例的任务类,而ProblemCallable是对该代码相应题目运行的测试类。 简单来说,就是一道题目有5个测试用例时(一份标准输入数据和一份标准输出数据构成一个测试用例),就会产生5个ProblemCallable对象。主要是用于减少跑测试用例的时间,详细的内容会在后面博文:《并行运行测试》中提及。CacheOutputStream类和ThreadInputStream类将会在后面博文:《并行运行测试》中的输入输出分流部分提及。

package cn.superman.sandbox.callable;import java.lang.reflect.Field;import java.lang.reflect.Method;import java.util.ArrayList;import java.util.List;import java.util.concurrent.Callable;import java.util.concurrent.CancellationException;import java.util.concurrent.CountDownLatch;import java.util.concurrent.ExecutorService;import java.util.concurrent.Executors;import java.util.concurrent.Future;import java.util.concurrent.FutureTask;import java.util.concurrent.ThreadFactory;import java.util.concurrent.TimeUnit;import java.util.concurrent.TimeoutException;import cn.superman.sandbox.core.systemInStream.ThreadInputStream;import cn.superman.sandbox.core.systemOutStream.CacheOutputStream;import cn.superman.sandbox.dto.Problem;import cn.superman.sandbox.dto.ProblemResultItem;public class ProblemCallable implements Callable<List<ProblemResultItem>> {private Method mainMethod;private Problem problem;private CacheOutputStream resultBuffer;private Runtime run = null;private CountDownLatch countDownLatch = null;private ThreadInputStream threadSystemIn;private static final ExecutorService itemGetThreadPool = Executors.newCachedThreadPool(new ThreadFactory() {@Overridepublic Thread newThread(Runnable r) {Thread thread = new Thread(r);thread.setName("itemGetThreadPool id "+ System.currentTimeMillis());return thread;}});private static final ExecutorService itemExecThreadPool = Executors.newCachedThreadPool(new ThreadFactory() {@Overridepublic Thread newThread(Runnable r) {Thread thread = new Thread(r);thread.setName("itemExecThreadPool id "+ System.currentTimeMillis());return thread;}});public ProblemCallable(Method mainMethod, Problem problem,CacheOutputStream resultBuffer, ThreadInputStream threadSystemIn) {this.mainMethod = mainMethod;this.problem = problem;this.resultBuffer = resultBuffer;this.threadSystemIn = threadSystemIn;run = Runtime.getRuntime();}@Overridepublic List<ProblemResultItem> call() throws Exception {List<String> paths = problem.getInputDataFilePathList();final List<ProblemResultItem> resultItems = new ArrayList<ProblemResultItem>();countDownLatch = new CountDownLatch(paths.size());// 为了内存使用比较准确,先大概的执行一次回收吧run.gc();for (int i = 0; i < paths.size(); i++) {final String path = paths.get(i);itemExecThreadPool.execute(new Runnable() {@Overridepublic void run() {resultItems.add(process(path));}});}// 阻塞线程,等待所有结果都计算完了,再返回countDownLatch.await();return resultItems;}private ProblemResultItem process(String inputFilePath) {ProblemResultItem item = null;ProblemItemCallable itemCallable = null;long beginMemory = 0;long beginTime = 0;long endTime = 0;long endMemory = 0;Future<ProblemResultItem> submit = null;try {itemCallable = new ProblemItemCallable(mainMethod, inputFilePath,resultBuffer, threadSystemIn);submit = itemGetThreadPool.submit(itemCallable);beginMemory = run.totalMemory() - run.freeMemory();beginTime = System.nanoTime();item = submit.get(problem.getTimeLimit() + 2, TimeUnit.MILLISECONDS);if (item == null) {killThread((FutureTask<ProblemResultItem>) submit);throw new TimeoutException();}endTime = System.nanoTime();endMemory = run.totalMemory() - run.freeMemory();} catch (Exception e) {// 出现了意外,先关闭资源再说(如已经打开的流等)itemCallable.colseResource();killThread((FutureTask<ProblemResultItem>) submit);item = new ProblemResultItem();item.setNormal(false);if (e instanceof CancellationException|| e instanceof TimeoutException) {// 超时了,会进来这里item.setMessage("超时");} else {item.setMessage(e.getMessage());}endTime = System.nanoTime();endMemory = run.totalMemory() - run.freeMemory();}// 时间为毫微秒,要先转变为微秒再变为毫秒item.setUseTime((endTime - beginTime) / 1000 / 1000);item.setUseMemory(endMemory - beginMemory);item.setInputFilePath(inputFilePath);if (item.getUseMemory() > problem.getMemoryLimit()) {item.setNormal(false);item.setMessage("超出内存限制");}// 无论怎么样,这里必须最后都要进行减一,不然将会一直阻塞线程,最终无法返回结果countDownLatch.countDown();return item;}/** * 需要注意的是,这里将会调用线程stop方法,因为只有这样才能强行终止超时的线程,而又因为这里并不需要保证什么原子性以及一致性的业务要求, * 所以用stop方法是没什么大问题的 *  * @param submit * @throws NoSuchFieldException * @throws SecurityException * @throws IllegalArgumentException * @throws IllegalAccessException */@SuppressWarnings("deprecation")private void killThread(FutureTask<ProblemResultItem> submit) {try {submit.cancel(true);// 利用反射,强行取出正在运行该任务的线程Field runner = submit.getClass().getDeclaredField("runner");runner.setAccessible(true);Thread execThread = (Thread) runner.get(submit);execThread.stop();submit.cancel(true);} catch (Exception e) {System.err.println(e);}}public Problem getProblem() {return problem;}public void setProblem(Problem problem) {this.problem = problem;}}

预告

    在本篇博文中,我们已经实现了限时运行。下一篇博文中,我们将开始利用多线程的方式,加速答案代码的测试,以及如何解决多线程所带来的资源冲突问题。

    PS:为什么要加速测试呢?我们试想一下,假设一道题目有5份测试用例(数据),也就是5份标准输入数据,5份标准输出数据。用户的代码跑一份测试用例(数据)平均需要消耗500毫秒的话,如果我们是串行的方式进行的话,跑完5份就需要2.5秒了。但是,如果我们利用多线程,同时进行5份测试的话,我们就只需要500毫秒(实际上会多一点点时间)就可以了。但是,因为涉及到多线程了,就涉及到了冲突等问题~