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

 前言

    在上一篇的博客中,我们通过设置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 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毫秒(实际上会多一点点时间)就可以了。但是,因为涉及到多线程了,就涉及到了冲突等问题~

你可能感兴趣的:(Online,Judge(纯JAVA实现))