使用Thread Pool不当引发的死锁

简介

多线程锁定同一资源会造成死锁

线程池中的任务使用当前线程池也可能出现死锁

RxJava 或 Reactor 等现代流行库也可能出现死锁

死锁是两个或多个线程互相等待对方所拥有的资源的情形。举个例子,线程 A 等待 lock1,lock1 当前由线程 B 锁住,然而线程 B 也在等待由线程 A 锁住的 lock2。最坏情况下,应用程序将无限期冻结。让我给你看个具体例子。假设这里有个 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();

        }

    }

}

可以看到,有两种伐木工:先戴好安全帽然后再拿电锯的,另一种则相反。谨慎派(careful())伐木工先戴好安全帽,然后去拿电锯。狂野派伐木工(yolo())先拿电锯,然后找安全帽。让我们并发生成一些伐木工:

private List<Lumberjack> generate(int count, Supplier<Lumberjack> factory) {

    return IntStream

            .range(0, count)

            .mapToObj(x -> factory.get())

            .collect(toList());

}

generate()方法可以创建指定类型伐木工的集合。我们来生成一些谨慎派伐木工和狂野派伐木工。

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(/* ... */);

            });

        });

这个循环让所有伐木工一个接一个(轮询方式)去砍树。实质上,我们向线程池(ExecutorService)提交了和树木数量(howManyTrees)相同个数的任务,并使用 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");

}

其实想法很简单。我们让多个伐木工(Lumberjacks)通过多线程方式去竞争一个安全帽和一把电锯。完整代码如下:

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 Lumberjacks),应用程序几乎瞬间运行完成,举个例子:

ExecutorService pool = Executors.newFixedThreadPool(10);

Logging logging = new Logging(new Names());

try (Forest forest = new Forest(pool, logging)) {

    forest.cutTrees(10000, 10, 0);

} catch (TimeoutException e) {

    log.warn("Working for too long", e);

}

但是,如果你对伐木工(Lumberjacks)的数量做些修改,比如,10 个谨慎派(careful)伐木工和 1 个狂野派(yolo)伐木工,系统就会经常运行失败。怎么回事?谨慎派(careful)团队里每个人都首先尝试获取安全帽。如果其中一个伐木工取到了安全帽,其他人会等待。然后那个幸运儿肯定能拿到电锯。原因就是其他人在等待安全帽,还没到获取电锯的阶段。目前为止很完美。但是如果团队里有一个狂野派(yolo)伐木工呢?当所有人竞争安全帽时,他偷偷把电锯拿走了。这就出现问题了。某个谨慎派(careful)伐木工牢牢握着安全帽,但他拿不到电锯,因为被其他某人拿走了。更糟糕的是电锯所有者(那个狂野派伐木工)在拿到安全帽之前不会放弃电锯。这里并没有一个超时设定。那个谨慎派(careful)伐木工拿着安全帽无限等待电锯,那个狂野派(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() 阻塞线程,在显示“Third”之前必须等待内部线程(Runnable)运行完成。这是个大坑!等待内部任务完成意味着需要从线程池额外获取一个线程来执行任务。然而,我们已经使用到了一个线程,所以内部任务在获取到第二个线程前将一直阻塞。当前我们的线程池足够大,运行没问题。让我们稍微改变一下代码,将线程池缩减到只有一个线程,另外关键的一点是我们移除 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”的任务占用,所以这个外层任务继续执行,并打印“Third”。当这个任务完成时,将单个线程释放回线程池,内部任务最终开始执行,并打印“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() 方法无限等待,无法获取线程

死锁

这是否意味单线程的线程池是不好的?并不是,相同的问题会在任意大小的线程池中出现,只不过是在高负载情况下才会出现,这维护起来更加困难。你在技术层面上可以使用一个无界线程池,但这样太糟糕了。

Reactor/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);

当你部署代码,它似乎可以正常工作,但很不符合编程习惯。根源的问题是相通的,最后一行的 subscribeOn() 表示外层任务(Runnable)请求了线程池(pool)中一个线程,同时,内部任务(Runnable)也试图获取一个线程。如果把基础的线程池换成只包含单个线程的线程池,会发生死锁。对于 RxJava/Reactor 来说,解决方案很简单——用异步操作替代阻塞操作。

Mono

    .fromRunnable(() -> {

        log.info("First");

        log.info("Third");

    })

    .then(Mono

            .fromRunnable(() -> log.info("Second"))

            .subscribeOn(pool))

    .subscribeOn(pool)

防患于未然

并没有彻底避免死锁的方法。试图解决问题的技术手段往往会带来死锁风险,比如共享资源和排它锁。如果无法根治死锁(或死锁并不明显,比如使用线程池),还是试着保证代码质量、监控线程池和避免无限阻塞。我很难想象你情愿无限等待程序运行完成,如同 get() 方法和 block() 方法在没有设定超时时间的情况下执行。

欢迎工作一到五年的Java工程师朋友们加入Java架构开发: 854393687

群内提供免费的Java架构学习资料(里面有高可用、高并发、高性能及分布式、Jvm性能调优、Spring源码,MyBatis,Netty,Redis,Kafka,Mysql,Zookeeper,Tomcat,Docker,Dubbo,Nginx等多个知识点的架构资料)合理利用自己每一分每一秒的时间来学习提升自己,不要再用"没有时间“来掩饰自己思想上的懒惰!趁年轻,使劲拼,给未来的自己一个交代!

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 205,033评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 87,725评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,473评论 0 338
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,846评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,848评论 5 368
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,691评论 1 282
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,053评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,700评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 42,856评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,676评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,787评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,430评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,034评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,990评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,218评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,174评论 2 352
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,526评论 2 343

推荐阅读更多精彩内容

  • 写在前面的话 代码中的# > 表示的是输出结果 输入 使用input()函数 用法 注意input函数输出的均是字...
    FlyingLittlePG阅读 2,730评论 0 8
  • Spring Cloud为开发人员提供了快速构建分布式系统中一些常见模式的工具(例如配置管理,服务发现,断路器,智...
    卡卡罗2017阅读 134,598评论 18 139
  • 高阶函数:将函数作为参数 sortted()它还可以接收一个key函数来实现自定义的排序,reversec参数可反...
    royal_47a2阅读 678评论 0 0
  • 起初,我想为你写封情书,用微风为纸柳芽为笔,给你寄去我闪烁的目光与难言的悸动。 后来,我想为你写封情书,用夏阳为墨...
    无兰阅读 135评论 0 0
  • 导语:其实我家境并不差,父母双职工,爸爸还是个派出所的所长,可因为从小不受重视的原因吧,缺少父爱母爱的我轻易被骗,...
    孤路人阅读 1,048评论 5 9