一个多线程问题引发的思考:论规范使用日志的重要性

1. 问题描述

问题来源于某开源项目,在程序运行过程中,如果连接的数据库停掉,会导致程序卡死,问题简化后的代码如下:

@Test
public void testThreadPoolSubmitBlocked() {
    ExecutorService workerExecutor = Util.newSingleThreadExecutor(5000L);
    ((ThreadPoolExecutor) workerExecutor).setThreadFactory(new ThreadFactoryBuilder().setNameFormat("workerExecutor-pool-%d").build());

    ExecutorService groupInnerExecutorService = Util.newFixedThreadPool(1, 5000L);
    ((ThreadPoolExecutor) groupInnerExecutorService).setThreadFactory(new ThreadFactoryBuilder().setNameFormat("group-inner-pool-%d").build());

    for (int i = 0; i < 5; i++) {
        log.info("committing new task");
        Future<Boolean> workerExecutorFuture = workerExecutor.submit(() -> {
            Future<Boolean> groupInnerFuture = groupInnerExecutorService.submit(new Callable<Boolean>() {
                @Override
                public Boolean call() throws Exception {
                    try {
                        Thread.sleep(10000); //6s正常运行,10s时好时坏,20s卡死
                    } catch (InterruptedException e) {
                        log.info("interrupted");
                        throw new RuntimeException("child thread was interrupted!");
                    }
                    return true;
                }
            });

            try {
                groupInnerFuture.get();
            } catch (Exception e) {
                throw new RuntimeException(e);
            }
            return true;
        });

        log.info("task committed");

        try {
            Boolean aBoolean = workerExecutorFuture.get(5, TimeUnit.SECONDS);
            if (aBoolean != null && aBoolean) {
                break;
            }
        } catch (Exception e) {
            workerExecutorFuture.cancel(true);
        }
    }
}

Util.java:

public static ThreadPoolExecutor newFixedThreadPool(int nThreads, long keepAliveTime) {
    return new ThreadPoolExecutor(nThreads,
        nThreads,
        keepAliveTime,
        TimeUnit.MILLISECONDS,
        new SynchronousQueue<>(),
        (r, exe) -> {
            if (!exe.isShutdown()) {
                try {
                    exe.getQueue().put(r);
                } catch (InterruptedException e) {
                    // ignore
                }
            }
        });
}

因为Util类被在较多地方引用,为降低影响没有修改Util类来设置线程池名称。
运行上面的测试代码,输出日志内容大致如下:

09:22:48.206 [main] INFO  c.a.o.c.c.adapter.support.UtilTest - committing new task
09:22:48.212 [main] INFO  c.a.o.c.c.adapter.support.UtilTest - task committed
09:22:53.228 [main] INFO  c.a.o.c.c.adapter.support.UtilTest - committing new task
09:22:53.229 [main] INFO  c.a.o.c.c.adapter.support.UtilTest - task committed
09:22:58.243 [main] INFO  c.a.o.c.c.adapter.support.UtilTest - committing new task
09:22:58.244 [main] INFO  c.a.o.c.c.adapter.support.UtilTest - task committed
09:23:03.248 [main] INFO  c.a.o.c.c.adapter.support.UtilTest - committing new task

然后线程一直阻塞。

2. 原因分析

上面的多线程问题,原因如下:

workerExecutorFuture.cancel(true)方法会导致内部任务线程中断;

而内部线程恰好阻塞在groupInnerExecutorService.submit()方法处,更精确一点,是在Util类中exe.getQueue().put(r)处;

当中断发生时,内部线程会抛出InterruptedException异常,然后进入到catch块,然而catch块中什么也没做;

这样就导致groupInnerExecutorService.submit()方法返回了一个Future实例,但这个实例对应的任务永远不会被线程池执行;

groupInnerFuture.get()会一直阻塞。

3. 问题思考

上面的问题症结在于这一处代码:

try {
    exe.getQueue().put(r);
} catch (InterruptedException e) {
    // ignore
}

这里进入到异常分支却什么也没做,导致出现问题却没有任何提示,对排查问题造成了不少障碍,如果打印一行错误日志,将会使问题更容易定位。而如果改成下面这样,阻塞的问题将不会出现:

try {
    exe.getQueue().put(r);
} catch (InterruptedException e) {
    // ignore
    logger.error("put runnable to queue failed");
    throw new RuntimeException("put runnable to queue failed, caused by: ", e);
}

原因是抛出异常会使groupInnerExecutorService.submit()方法不返回结果,而是直接将异常继续上抛导致线程终止。如果将workerExecutor.submit(()->{ ... }) 内部代码块用try catch进行异常的捕获并打印日志,那么异常信息将会被打印。

上面的问题是项目中的问题被复现后再简化得到的,实际项目中的源码比上面复杂得多。事实上将项目中的问题简化成上面的代码就花了好几天。事后总结了一下,认为如果先对日志进行优化,也许问题会解决地快一点。

4. 不规范使用日志示例

上面的问题只暴露出项目中日志使用不规范的一些,还有其它不规范的做法也导致问题定位的困难,这里总结如下:

4.1 不打印异常

如前所述,捕捉到异常后不做任何事,一旦异常真的发生,且导致问题出现,在复杂项目中可能会极难定位。除非真的确定异常发生不会有影响,否则至少还是打印一下日志吧。

4.2 线程池任务异常不捕获

如前所述,workerExecutor.submit(()->{ ... }) 内部代码块最好用 try catch 包围,然后打印一下日志,如果有必要的话,可以再次抛出异常。

4.3 logger对象定义为protected

protected final Logger logger = LoggerFactory.getLogger(this.getClass());

上面定义Logger对象的方式的有问题的,this.getClass()获取的是实例的Class, 而不是类的Class, 这样父类中打印日志时,显示的类名可能为子类(实例为子类对象)。

显然this.getClass()改成 Test.class 也是有问题的,这会导致子类打印日志显示父类的类名。事实上,logger对象不应该在父类和子类间共享,所以定义为protected 是不合适的。

一般还是应采用如下写法:

private static final Logger logger = LoggerFactory.getLogger(Test.class);

如果嫌麻烦,就用lombok或者IDEA自动生成代码吧。

4.4 异常日志没有说明

logger.error(e.getMessage(), e);

当捕捉到异常时,打印日志采用上面写法虽然省事,但却不便于根据日志快速定位至代码位置,一般应简单说明错误内容。如:

logger.error("send message error!", e);

4.5 线程池没有设置名称

在遇到较复杂的多线程问题时,没有命名的线程池会使问题更难定位。

4.6 异常日志不打印堆栈

logger.error(e.getMessage() + " send message error!");

这种写法不打印堆栈信息,仅通过e.getMessage()可能根本无法推断出异常的具体来源。

5. 总结

上面这些示例都是本次在定位问题过程中发现的,为解决问题不得不一点点优化日志打印,直至最后问题解决。
回过头来想一想,在定位问题前,若一开始就把日志优化好,再来定位问题,也许效率会更高。

posted @ 2020-12-02 14:15  codercg  阅读(564)  评论(0编辑  收藏  举报