在上一篇的博客中,我们通过设置SecurityManager已经实现了大部分的安全措施。这里我们将实现最后的安全措施,防止用户提交死循环的代码无止境的消耗服务器的CPU,以及防止用户恶意破坏沙箱运行代码的能力。同时,因为OJ业务要求的限制,每一道题目的答案代码运行都应该是限时的(比如限时1000毫秒内出结果),对于运行超过指定时间还未出结果的,我们就应该终止运行的线程,并判定这个测试用例这份代码超时了。
我们应该都知道JAVA的线程是协作式而非抢占式的。也就是说对于运行已经超时的线程,或者因为死循环而一直在工作的线程,我们是无法使用JAVA自带的中断API(如Thread类的interrupt方法)进行有效停止的,线程还会依旧运行下去。由于我们无法限制用户的算法,而有些算法可能本身因为出现了漏洞,导致了死循环的出现。总而言之,对于我们通过反射调用main方法运行起来的用户代码,一旦死循环了或者超时了,我们是无法通过JAVA推荐的中断的方式,去终止运行用户代码的线程。
要解决上述问题,需要用到一个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方法。而且,因为OJ业务的特殊性,stop方法的危害对于我们来说,基本上都不是危害。
为什么这么说呢?因为当代码运行超时时,其实我们已经得出了该代码对于这份测试用例的结果了,再由于我们的代码不会跟用户提交的代码涉及到任何相关的监视器、锁。因此,放心大胆的使用stop方法吧!
下面给出,在本系统沙箱端中,调用杀死线程的主要函数(需要主要的是,因为我们采用了future模式,因此需要用到反射,拿到真正运行改任务的工作线程,顺便说明的是submit.cancel(true)其实也是利用中断机制的,面对死循环等情况,也是无力的,这里使用,仅仅是因为~我就是想调用一下而已0.0):
@SuppressWarnings("deprecation")
private void killThread(FutureTask 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> {
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() {
@Override
public 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() {
@Override
public 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();
}
@Override
public List call() throws Exception {
List paths = problem.getInputDataFilePathList();
final List resultItems = new ArrayList();
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() {
@Override
public 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 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) submit);
throw new TimeoutException();
}
endTime = System.nanoTime();
endMemory = run.totalMemory() - run.freeMemory();
} catch (Exception e) {
// 出现了意外,先关闭资源再说(如已经打开的流等)
itemCallable.colseResource();
killThread((FutureTask) 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 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毫秒(实际上会多一点点时间)就可以了。但是,因为涉及到多线程了,就涉及到了冲突等问题~