线程池自引发死锁

线程池自引发死锁
强烈推介IDEA2020.2破解激活,IntelliJ IDEA 注册码,2020.2 IDEA 激活码

线程池自引发死锁

  1. 死锁是由许多线程锁定相同资源引起的

  2. 如果在该池中运行的任务内使用线程池,也会发生死锁

  3. 像RxJava / Reactor这样的现代图书馆也很容易受到影响

死锁是两个或多个线程正在等待彼此获取的资源的情况。例如,线程A等待lock1线程B锁定,而线程B等待lock2,由线程A锁定。在最坏的情况下,应用程序冻结无限期的时间。让我向您展示一个具体的例子。想象一下,有一个Lumberjack类可以保存对两个附件锁的引用:

import com.google.common.collect.ImmutableList;
import lombok.RequiredArgsConstructor;

import java.util.concurrent.locks.Lock;

@RequiredArgsConstructor
class Lumberjack {

   private final String name;
   private final Lock accessoryOne;
   private final Lock accessoryTwo;

   void cut(Runnable work) {
       try {
           accessoryOne.lock();
           try {
               accessoryTwo.lock();
               work.run();
           } finally {
               accessoryTwo.unlock();
           }
       } finally {
           accessoryOne.unlock();
       }
   }
}

每个伐木工人都需要两个配件:头盔和电锯。在他接近之前work,他必须对这两者保持独占锁定。我们按如下方式创建伐木工人:

import lombok.RequiredArgsConstructor;

import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

@RequiredArgsConstructor
class Logging {

   private final Names names;

   private final Lock helmet = new ReentrantLock();
   private final Lock chainsaw = new ReentrantLock();

   Lumberjack careful() {
       return new Lumberjack(names.getRandomName(), helmet, chainsaw);
   }

   Lumberjack yolo() {
       return new Lumberjack(names.getRandomName(), chainsaw, helmet);
   }

}

正如你所看到的那样,有两种伐木工人:首先是头盔,然后是电锯,反之亦然。小心翼翼的伐木工人首先尝试获得头盔,然后等待电锯。YOLO型的伐木工人首先拿电锯然后寻找头盔。让我们生成一些伐木工人并同时运行它们:

rivate List<Lumberjack> generate(int count, Supplier<Lumberjack> factory) {
   return IntStream
           .range(0, count)
           .mapToObj(x -> factory.get())
           .collect(toList());
}

generate()是一种创建给定类型的伐木工人集合的简单方法。然后我们生成了一堆细心和yolo伐木工人:

private final Logging logging;

//...

List<Lumberjack> lumberjacks = new CopyOnWriteArrayList<>();
lumberjacks.addAll(generate(carefulLumberjacks, logging::careful));
lumberjacks.addAll(generate(yoloLumberjacks, logging::yolo));

最后让我们把这些伐木工人投入使用:

IntStream
       .range(0, howManyTrees)
       .forEach(x -> {
           Lumberjack roundRobinJack = lumberjacks.get(x % lumberjacks.size());
           pool.submit(() -> {
               log.debug("{} cuts down tree, {} left", roundRobinJack, latch.getCount());
               roundRobinJack.cut(/* ... */);
           });
       });

这个循环以循环方式一个接一个地接受伐木工人并要求他们砍树。基本上我们将howManyTrees一些任务提交给一个线程pool(ExecutorService)。为了弄清楚工作何时完成,我们使用CountDownLatch:

CountDownLatch latch = new CountDownLatch(howManyTrees);
IntStream
       .range(0, howManyTrees)
       .forEach(x -> {
           pool.submit(() -> {
               //...
               roundRobinJack.cut(latch::countDown);
           });
       });
if (!latch.await(10, TimeUnit.SECONDS)) {
   throw new TimeoutException("Cutting forest for too long");
}

这个想法很简单 - 让一群伐木工人通过头盔和电锯在多个线程上竞争。完整的源代码如下:

import lombok.RequiredArgsConstructor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.function.Supplier;
import java.util.stream.Collectors;
import java.util.stream.IntStream;

@RequiredArgsConstructor
class Forest implements AutoCloseable {
   private static final Logger log = LoggerFactory.getLogger(Forest.class);

   private final ExecutorService pool;
   private final Logging logging;

   void cutTrees(int howManyTrees, int carefulLumberjacks, int yoloLumberjacks) throws InterruptedException, TimeoutException {
       CountDownLatch latch = new CountDownLatch(howManyTrees);
       List<Lumberjack> lumberjacks = new ArrayList<>();
       lumberjacks.addAll(generate(carefulLumberjacks, logging::careful));
       lumberjacks.addAll(generate(yoloLumberjacks, logging::yolo));
       IntStream
               .range(0, howManyTrees)
               .forEach(x -> {
                   Lumberjack roundRobinJack = lumberjacks.get(x % lumberjacks.size());
                   pool.submit(() -> {
                       log.debug("{} cuts down tree, {} left", roundRobinJack, latch.getCount());
                       roundRobinJack.cut(latch::countDown);
                   });
               });
       if (!latch.await(10, TimeUnit.SECONDS)) {
           throw new TimeoutException("Cutting forest for too long");
       }
       log.debug("Cut all trees");
   }

   private List<Lumberjack> generate(int count, Supplier<Lumberjack> factory) {
       return IntStream
               .range(0, count)
               .mapToObj(x -> factory.get())
               .collect(Collectors.toList());
   }

   @Override
   public void close() {
       pool.shutdownNow();
   }
}

现在有趣的部分。如果您只创建careful伐木工人,应用程序几乎立即完成,例如:

ExecutorService pool = Executors.newFixedThreadPool(10);
Logging logging = new Logging(new Names());
try (Forest forest = new Forest(pool, logging)) {
   forest.cutTrees(10_000, 10, 0);
} catch (TimeoutException e) {
   log.warn("Working for too long", e);
}

但是,如果你玩一些伐木工人的数量,例如10小心和一个yolo,系统经常会失败。发生了什么?细心的团队中的每个人都会先尝试拿起头盔。如果其中一名伐木工人拿起头盔,其他人都会等待。然后幸运的家伙拿起一个必须可用的电锯。为什么?其他人在拿起电锯之前都在等头盔。到现在为止还挺好。但如果团队中有一名yolo伐木工人怎么办?当每个人都争夺头盔时,他偷偷地抓住了电锯。但是有一个问题。一个仔细的伐木工人得到了他的安全头盔。然而,他不能拿起电锯,因为它已经被别人拿走了。更糟糕的是,电锯的当前所有者(yolo家伙)在他拿到头盔之前不会释放他的电锯。这里没有超时。细心的家伙用头盔无限地等待,无法找到电锯。yolo家伙永远无所事事,因为他无法获得头盔。陷入僵局。

现在,如果所有伐木工人都是yolo,会发生什么事,即他们都试图先挑选电锯?原来避免死锁的最简单方法是始终以相同的顺序获取和释放锁。例如,您可以根据某些任意条件对资源进行排序。如果一个线程获得锁定A跟随B,而第二个线程首先获得B,则它是死锁的配方。

线程池自引发死锁

这是一个僵局的例子,相当简单。但事实证明,如果使用不正确,单个线程池可能会导致死锁。想象一下,你有一个ExecutorService(就像在前面的例子中一样)你这样使用:

ExecutorService pool = Executors.newFixedThreadPool(10); pool.submit(() - > {
   try {
       log.info(“First”);
       pool.submit(() - > log.info(“Second”))。get();
       log.info(“Third”) ;
   } catch(InterruptedException | ExecutionException e){
       log.error(“Error”,e);
   } });

这看起来很好,所有消息都按预期显示在屏幕上:

INFO [pool-1-thread-1]: First
INFO [pool-1-thread-2]: Second
INFO [pool-1-thread-1]: Third

请注意,我们阻止(请参阅get())Runnable在显示之前等待内部完成"Third"。这是一个陷阱!等待内部任务完成意味着它必须从线程获取一个线程pool才能继续。但是我们已经获得了一个线程,因此内部将阻塞,直到它可以得到第二个。我们的线程池目前足够大,所以它工作正常。让我们稍微改变一下代码,将线程池缩小到一个线程。此外,我们将删除get(),这是至关重要的:

ExecutorService pool = Executors.newSingleThreadExecutor(); pool.submit(() - > {
   log.info(“First”);
   pool.submit(() - > log.info(“Second”));
   log.info(“Third”); }

代码工作正常,但有一个扭曲:

INFO [pool-1-thread-1]: First
INFO [pool-1-thread-1]: Third
INFO [pool-1-thread-1]: Second

有两点需要注意:

  • 一切都在一个线程中运行(不出所料)

  • 在"Third"之前出现的消息"Second"

订单的变化是完全可预测的,并不是来自线程之间的某些竞争条件(事实上,我们只有一个)。仔细观察会发生什么:我们向线程池提交一个新任务(一次打印"Second")。但是,这次我们不等待完成该任务。伟大的,因为在一个线程池非常单一线程已经被任务所占用的印刷"First"和"Third"。因此,外部任务继续,打印"Second"。当此任务完成时,它将单个线程释放回线程池。内部任务终于可以开始执行,打印"Second"。现在陷入僵局的地方?尝试向get()内部任务添加阻止:

ExecutorService pool = Executors.newSingleThreadExecutor(); pool.submit(() - > {
   try {
       log.info(“First”);
       pool.submit(() - > log.info(“Second”))。get();
       log.info(“Third”) ;
   } catch(InterruptedException | ExecutionException e){
       log.error(“Error”,e);
   } });

僵局!一步步:

  • 任务打印"First"提交到空闲的单线程池

  • 此任务开始执行并打印 “First”

  • 我们向"Second"线程池提交内部任务打印

  • 内部任务落在待处理任务队列中 - 没有线程可用,因为当前只有一个线程被占用

  • 我们阻止等待内部任务的结果。不幸的是,在等待内部任务时,我们持有唯一可用的线程

  • get() 将永远等待,无法获得线程

  • 僵局

这是否意味着单线程池是坏的?并不是的。任何大小的线程池都可能出现同样的问题。但在这种情况下,只有在高负载下才会出现死锁,从维护的角度来看,这种情况要糟糕得多。从技术上讲,你可以拥有一个无限制的线程池,但情况更糟。

反应堆/ RxJava

请注意,像Reactor这样的高级库可能会出现此问题:

Scheduler pool = Schedulers.fromExecutor(Executors.newFixedThreadPool(10));
Mono
   .fromRunnable(() -> {
       log.info("First");
       Mono
               .fromRunnable(() -> log.info("Second"))
               .subscribeOn(pool)
               .block();  //VERY, VERY BAD!
       log.info("Third");
   })
   .subscribeOn(pool);

一旦你订阅,这似乎工作,但非常非惯用。基本问题是一样的。外部Runnable从pool(subscribeOn()从最后一行)获取一个线程,同时内部Runnable尝试获取线程。用单线程池替换底层线程池,这会产生死锁。至少使用RxJava / Reactor,解决方法很简单 - 只需编写异步操作而不是相互阻塞:

Mono
   .fromRunnable(() - > {
       log.info(“First”);
       log.info(“Third”);
   })。
           then(
   Mono .fromRunnable(() - > log.info(“Second”))
           。 subscribeOn(pool)).
   subscribeOn(pool)

预防

没有100%的方法来防止死锁。一种技术是避免可能导致死锁的情况,例如共享资源或专门锁定。如果那是不可能的(或者死锁不明显,就像线程池一样),请考虑正确的代码卫生。监视线程池并避免无限期阻塞。当你愿意无限期地等待完成时,我很难想象这种情况。这就是如何get()或者block()不超时工作正常。

原文链接:https://gper.club/articles/7e7e7f7ff2g51gce

本文来源Java实战团,由架构君转载发布,观点不代表Java架构师必看的立场,转载请标明来源出处:https://javajgs.com/archives/7934

发表评论