1. 程式人生 > 實用技巧 >一個多執行緒問題引發的思考:論規範使用日誌的重要性

一個多執行緒問題引發的思考:論規範使用日誌的重要性

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. 總結

上面這些示例都是本次在定位問題過程中發現的,為解決問題不得不一點點優化日誌列印,直至最後問題解決。
回過頭來想一想,在定位問題前,若一開始就把日誌優化好,再來定位問題,也許效率會更高。