一.递归算法的并行化
1.如果在循环中包含了一些密集型计算,或者需要执行可能阻塞的I/O操作,那么只要每次迭代是独立的,都可以对其进行并行化。
2.如果循环中的迭代操作都是独立的,并且不需要等待所有迭代操作都完成后再继续执行,那么就可以使用Executor将串行循环转化为并行循环。如下:
//串行循环
void processSequentially(List<Element> elements){
for(Element e:elements)
process(e);
}
//将上面的串行循环转化为并行执行
void processInParallel(Executor exec , List<Element> elements){
for(final Element e: elements)
exec.execute(new Runnable(){
public void run(){ process(e); }
});
}
上面两个方法中,显然调用后者能更快的返回,因为processInParaller会再所有下载任务都进入了Executor的队列后就立即返回,而不会等待任务全部完成才返回。
所以我们有:当串行循环中的各个迭代操作之间彼此独立,并且每个迭代操作执行的工作量比管理一个新任务时带来的开销更多,那么这个串行循环就适合并行化。
3.同样在一些递归设计中也可以采用并行化的方式来加快执行速度:
//递归遍历树的节点
public<T> void sequentialRecursive(List<Node<T>> node,Collection<T> results){
for(Node<T> n:nodes){
results.add(n.compute());//将节点的数据存入results中
sequentialRecursive(n.getChildren(),results);//递归遍历下一个
}
}
//递归遍历转并行遍历
public<T> void parallelRecursive(final Executor exec ,List<Node<T>> nodes,
final Collection<T> results){
for(final Node<T> n:nodes){
exec.execute(new Runnable(){
public void run(){
results.add(n.compute());
}
});
parallelRecursive(exec,n.getChildren(),results);
}
}
当parallelRecursive返回时,树中的所有节点都已经访问过了,并且每个节点的计算任务也已经放入Executor的工作队列。 我们可以通过以下方式来等待所有的结果:
public<T> Collection<T> getParallelResults(List<Node<T>> nodes)
throws InterruptedException{
ExecutorService exec = Executors.newCachedThreadPool();
Queue<T> resultQueue = new ConcurrentLinkedQueue<T>();
ParallelRecursive(exec,nodes,resultQueue);
exec.shutdown();
exec.awaitTermination(Long.MAX_VALUE,TimeUnit.SECONDS);
return resultQueue;
}
二.谜题框架
递归算法的并行化技术的一种强大的应用就是解决一些谜题,这些谜题都需要找出一系列的操作从初始状态转换到目标状态。
1.谜题的定义:包含了一个初始位置,一个目标位置,以及用于判断是否有效移动的规则集。
其中,规则集包含两部分:计算从指定位置开始的所有合法移动,以及每次移动的结果位置。
2.下面给出表示谜题的抽象类:
public interface Puzzle<P,M>{
P initiaPosition();
boolean isGoal(P position);//判断是否到达目标位置
Set<M> legalMoves(P position);//合法移动集
P move(P position , M move);//移动
}
其中参数P和M表示位置类和移动类。根据这个接口,我们可以写一个简单的串行求解程序,该程序将在谜题空间中查找,直到找到一个解答或者找遍了整个空间都没有发现答案。
下面是用于谜题解决框架的链表节点:
static class Node<P,M>{
final P pos;//位置对象
final M move;//移动对象
final Node<P,M> prev;//指向上一个节点对象
//构造方法
Node(P pos, M move, Node<P,M> prev){
this.pos = pos;
this.move = move;
this.prev = prev;
}
//此方法用于获得解的移动路径
List<M> asMoveList(){
List<M> solution = new LinkedList<M>();
for(Node<P,M> n = this;n.move!=null;n = n.prev)
solution.add(0,n.move);
return solution;
}
}
1)下面代码给出谜题框架的串行解决方案:
public class SequentialPuzzleSolver<P,M>{
private final Puzzle<P,M> puzzle;//声明谜题
private final Set<P> seen = new HashSet<P>();//存储已经到过的位置
public SequentialPuzzleSolver(Puzzle<P,M> puzzle){
this.puzzle = puzzle;
}
public List<M> solve(){
P pos = puzzle.initialPosition();//获得初始位置
return search(new Node<P,M>(pos,null,null));
}
private List<M> search(Node<P,M> node){
if(!seen.contains(node.pos)){
seen.add(node.pos);
if(puzzle.isGogal(node.pos))
return node.asMoveList();//返回解
for(M move: puzzle.legalMoves(node.pos)){
P pos = puzzle.move(node.pos,move);
Node<P,M> child = new Node<P,M>(pos,move,node);
List<M> result = search(child);//接着迭代求解
if(result != null)
return result;
}
}
return null;
}
static class Node<P,M>{......}
}
上面代码在谜题空间中执行一个深度遍历,当找到解答方案后结束搜索。
2).我们可以通过修改解决方案以利用并发性,可以以并行方式来计算下一步移动以及目标条件,因为计算某次移动的过程在很大程度上与计算其他移动过程是相互独立的。(之所以说“在很大程度上” 是因为在各个任务之间会共享一些可变状态,例如已遍历位置的集合)。
下面是并发的谜题解答器:
public class ConcurrentPuzzleSolver<P,M>{
private final Puzzle<P,M> puzzle;//谜题
private final ExecutorService exec;//线程池
private final ConcurrentMap<P,Boolean> seen;//存放已访问位置
final ValueLatch<Node<P,M>> solution = new ValueLatch<Node<P,M>>();//存放结果
...
public List<M> solve() throws InterruptedException{
try{
P p = puzzle.initialPosition();//初始位置
exec.execute(newTask(p,null,null));
Node<P,M> solnNode = solution.getValue();//得到结果
return (solnNode == null) ? null : solnNode.asMoveList();//返回解
}finally{
exec.shutdown();
}
}
protected Runnable newTask(P p,M m,Node<P,M> n){
return new SolverTask(p,m,n);//返回一个下面定义的任务
}
class SolverTask extends Node<P,M> implements Runnable{
...
public void run(){
if(solution.isSet()||seen.putIfAbsent(pos,true)!=null)
return;
if(puzzle.isGoal(pos))
solution.seValue(this);
else
for(M m:puzzle.legalMoves(pos))
exec.execute(newTask(puzzle.move(pos,m),m,this));//并发进行求解
}
}
}
public class ValueLatch<T>{
private T value = null;
private final CountDownLatch done = new CountDownLatch(1);
public boolean isSet(){
return (done.getCount()==0);
}
public synchronized void setValue(T newValue){
if(!isSet()){
value = newValue;
done.countDown();
}
}
public T getValue() throws InterruptedException{
done.await();
synchronized (this){
return value;
}
}
}
上面代码大部分工作都是在run方法中完成的:首先计算下一步可能到达的所有位置,并去掉已经到达的位置,然后判断这个任务或者其他某个任务是否已经成功的完成,最后将尚未搜索过的位置提交给Executor。
ConcurrentHashMap用来保存之前已经搜索过的所有位置,这种做法不仅提供了线程安全性,还避免了在更新共享集合时存在的竟态条件,因为putIfAbsent只有在之前没有遍历过的某个位置才会通过原子方式添加到集合。ConcurrentPuzzleSolver使用线程池的内部工作队列而不是调用栈来保存搜索状态。
为了在找到某个解答后停止搜索,需要通过某种方式来检查是否有线程已经找到一个解答。 如果需要第一个找到的解答,那么还需要在其他任务都没有找到解答时更新解答。 这些需求描述的是一种包含结果的闭锁机制(上面的ValueLatch)。
每个任务首先查询solution闭锁,找到一个解答就停止。而在此之前,主线程需要等待,ValueLatch中的getValue将一直阻塞,直到有线程设置了这个值。 ValueLatch提供了一种方式来保存这个值,只有第一次调用才会设置它,调用者能够判断这个值是否已经被设置,以及阻塞并等候它被设置,在第一次调用setValue时,将更新解答方案,并且CountDownlatch会递减,从getValue中释放主线程。
第一个找到解答的线程还会关闭Executor,从而阻止新任务被执行。
但是问题来了:如果这个问题无解,上面的并行解答器就不能很好的处理了:如果已经遍历了所有的移动和位置都没有找到解答,那么在getSoolution调用中将永远等待下去。 如何解决呢?
我们可以记录活动任务的数量,当该值为0时将解答设置为null,如下:
public class PuzzleSolver<P,M> extends ConcurrentPuzzleSolver<P,M>{
...
private final AtomicInteger taskCount = new AtomicInteger(0);
protected Runnable newTask(P p,M m,Node<P,M> n){
return new CountingSolverTask(p,m,n);
}
class CountingSolverTask extends SolverTask{
CountingSolver(P pos,M move, Node<P,M> prev){
super(pos,move,prev);
taskCount.incrementAndGet();
}
public void run(){
try{
super.run();
}finally{
if(taskCount.decrementAndGet()==0)
solution.setValue(null);
}
}
}
}
总结:并行的目的就是相对于串行提高工作效率,所以我们要学会找到可并行化的串行实现,并努力让其并行化。