記一次執行緒池配置導致的ThreadLocal清空

KerryWu發表於2022-11-23

1. 現象

某天伺服器監控上發現大量介面報錯,檢視伺服器日誌,並且分析程式碼後,發現最直接的問題現象有:

  1. Controller 的主執行緒中,RequestContextHolder.getRequestAttributes() 返回的值,會突然在某個時刻返回的是 null,從而導致API的邏輯報錯。
  2. elk日誌中,spring cloud sleuth 框架裡面的 traceId,會在介面處理鏈路的某個環節突然丟失了,只有新建的spanId。
  3. elk日誌中,最大量的報錯日誌資訊,都是在於非同步請求一個第三方服務 timeout,但是請求這個第三方服務是透過執行緒池非同步執行的。

前面1、2 兩個問題暫時無思路,因為透過日誌來看,問題出現的環節無規律,也摸不著頭腦。但第3個問題則是很明確,那個第三方服務不可用了。但問題3畢竟是非同步執行緒執行的,原則上來講不應該影響主執行緒,雖說暫時看不到和第1、2問題的關聯點,可經驗上來看應該脫不了干係。

於是我們將對那個第三方服務的請求熔斷了。結果是慢慢的,伺服器錯誤逐漸減少,直到最終恢復正常。

現在基本可以肯定,問題1、2 是受問題3影響的。接下來就需要查清問題發生的原委。

2. 排查

進一步分析問題1、2,就能明顯看出來,這和執行緒有關。無論 RequestContextHolder 還是 MDC,都是基於 ThreadLocal 實現的,上述亂象表面,應該是本地執行緒 ThreadLocal 被動了。可到底是 ThreadLocal 的值被動了?還是當前執行緒被切換了呢?目前只能從問題3著手。

分析問題3的現象,當時第三方服務不可用,當呼叫服務不是直接被拒絕,而且在請求達到最大 timeout 限制後報錯。雖說整個過程是基於執行緒池非同步執行,可這類請求 timeout 的現象,對執行緒池帶來的結果是災難性的。

因為執行緒池中分配的執行緒在處理 timeout 請求時,也只能保持等待,等到超時時間後才回收執行緒。因為是非同步執行的,tomcat 執行緒池的處理效率並不會受影響,不斷的有API請求湧入儘量,並給業務執行緒池分配呼叫第三方服務的任務。而業務執行緒池下執行任務的每個執行緒因為 timeout 請求阻塞,會導致短時間內併發執行緒數量迅速飆升。

我們再著重搜一下出問題時,執行緒池的告警日誌,果然,當時執行緒池已經打滿,開始大量執行拒絕策略。

再看看我們業務執行緒池的配置。這個執行緒池的配置在我前面幾篇文章中出現過,之前以為是比較標準的,但這次發現有很大的坑。

    @Bean("customExecutor")
    public Executor getAsyncExecutor() {
        final RejectedExecutionHandler rejectedHandler = new ThreadPoolExecutor.CallerRunsPolicy() {
            @Override
            public void rejectedExecution(Runnable r, ThreadPoolExecutor e) {
                log.warn("LOG:執行緒池容量不夠,考慮增加執行緒數量,但更推薦將執行緒消耗數量大的程式使用單獨的執行緒池");
                super.rejectedExecution(r, e);
            }
        };
        ThreadPoolTaskExecutor threadPoolTaskExecutor = new ThreadPoolTaskExecutor();
        threadPoolTaskExecutor.setCorePoolSize(10);
        threadPoolTaskExecutor.setMaxPoolSize(50);
        threadPoolTaskExecutor.setQueueCapacity(2000);
        threadPoolTaskExecutor.setRejectedExecutionHandler(rejectedHandler);
        threadPoolTaskExecutor.setThreadNamePrefix("custom exec-");
        threadPoolTaskExecutor.setTaskDecorator(runnable -> {
            try {
                Optional<RequestAttributes> requestAttributesOptional = ofNullable(RequestContextHolder.getRequestAttributes());
                Optional<Map<String, String>> contextMapOptional = ofNullable(MDC.getCopyOfContextMap());
                return () -> {
                    try {
                        requestAttributesOptional.ifPresent(RequestContextHolder::setRequestAttributes);
                        contextMapOptional.ifPresent(MDC::setContextMap);
                        runnable.run();
                    } finally {
                        MDC.clear();
                        RequestContextHolder.resetRequestAttributes();
                    }
                };
            } catch (Exception e) {
                return runnable;
            }
        });
        return threadPoolTaskExecutor;
    }

3. 結論

我們著重關注執行緒池的兩個引數配置:

  1. 執行緒池拒絕策略: 拒絕策略是 CallerRunsPolicy,即:當執行緒池滿了之後,再有執行緒進來,將由對應請求的主執行緒來執行。
  2. 任務裝飾: 當執行緒池在分配子執行緒時,會先讓當前主執行緒將自身ThreadLocal 值複製給子執行緒。並在子執行緒執行完任務後,子執行緒清除 ThreadLocal 的值。

這些引數各自的邏輯都沒有問題,可結合到一起就出問題了。試想一下:

  1. API 主執行緒原本攜帶 ThreadLocal 資訊。此時需要基於業務執行緒池非同步執行任務。
  2. 但是由於業務執行緒池滿了,根據CallerRunsPolicy拒絕策略,只能由主執行緒自己來充當執行任務的“子執行緒”。
  3. 再根據執行緒任務裝飾的邏輯,在執行完業務執行緒池中的任務後,執行緒中的“子執行緒”(即:主執行緒)會被清空 ThreadLocal 資訊。
  4. 主執行緒在執行完上述任務後,就丟棄了原本攜帶的 ThreadLocal 資訊,從而出現之前的第1和第2個問題。

所以說這樣的執行緒池配置就是個大坑,只要執行緒池滿了,後續的執行緒執行了拒絕策略。就會導致主執行緒 ThreadLocal 資訊丟失。

問題總算找著了。

4. 解決

4.1. TaskDecorator 邏輯最佳化

回顧前面,發生的問題在於當執行拒絕策略時,TaskDecorator 的處理邏輯中,將主執行緒當子執行緒處理,做了 ThreadLocal 值當清除操作,最終導致主執行緒的 ThreadLocal 值丟失。那麼首先可以針對該現象解決的是,在 TaskDecorator 邏輯對當前執行緒做出區分,如果發現當前執行緒是主執行緒,則不進行 ThreadLocal 的賦值和清除。如下:

    @Bean("customExecutor")
    public Executor getCustomExecutor() {
        final RejectedExecutionHandler rejectedHandler = new ThreadPoolExecutor.CallerRunsPolicy() {
            @Override
            public void rejectedExecution(Runnable r, ThreadPoolExecutor e) {
                log.warn("LOG:執行緒池容量不夠,考慮增加執行緒數量,但更推薦將執行緒消耗數量大的程式使用單獨的執行緒池");
                super.rejectedExecution(r, e);
            }
        };
        ThreadPoolTaskExecutor threadPoolTaskExecutor = new ThreadPoolTaskExecutor();
        threadPoolTaskExecutor.setCorePoolSize(2);
        threadPoolTaskExecutor.setMaxPoolSize(2);
        threadPoolTaskExecutor.setQueueCapacity(0);
        threadPoolTaskExecutor.setRejectedExecutionHandler(rejectedHandler);
        threadPoolTaskExecutor.setThreadNamePrefix("custom-exec-");
        threadPoolTaskExecutor.setTaskDecorator(runnable -> {
            try {
                Thread mainThread = Thread.currentThread();
                Optional<RequestAttributes> requestAttributesOptional = ofNullable(RequestContextHolder.getRequestAttributes());
                Optional<Map<String, String>> contextMapOptional = ofNullable(MDC.getCopyOfContextMap());
                return () -> {
                    Thread subThread = Thread.currentThread();
                    if (!subThread.equals(mainThread)) {
                        try {
                            requestAttributesOptional.ifPresent(RequestContextHolder::setRequestAttributes);
                            contextMapOptional.ifPresent(MDC::setContextMap);
                            runnable.run();
                        } finally {
                            MDC.clear();
                            RequestContextHolder.resetRequestAttributes();
                        }
                    } else {
                        runnable.run();
                    }
                };
            } catch (Exception e) {
                return runnable;
            }
        });
        return threadPoolTaskExecutor;
    }

4.2. TransmittableThreadLocal

TransmittableThreadLocal (簡稱TTL)是阿里推出的工具庫,專門解決執行緒複用情況下變數傳遞問題。

這個工具能解決上述問題的關鍵在於下面的比較:

  • TaskDecorator:(1)父執行緒給子執行緒賦值;(2)子執行緒執行完成後清空 TreadLocal值。
  • TTL:(1)先給子執行緒做備份backup;(2)父執行緒給子執行緒賦值;(3)子執行緒執行完成後清空 TreadLocal值;(4)利用之前的backup,給子執行緒restore還原回剛來的樣子。

如果是 TTL 的設計,就算按照拒絕策略,由主執行緒來執行緒池中執行非同步,也因為backup還原回原來的值,並不會有任何丟值的現象。

具體的使用方法和文件,請參考 TTL github,這裡就不多介紹。

但是,TTL 這個工具的框架封裝上,沒 TaskDecorator 那麼靈活,它純粹為了業務使用的 TreadLocal 變數服務的。如果你希望和上面一樣,針對 RequestContextHolder、MDC也能實現,就比較難了。但如果你只是自己定義 ThreadLocal 值,希望子執行緒依然能使用,不妨使用它,再專門定義一個 TtlExecutors 類方法封裝後的執行緒池。

5. 其他知識點

5.1. 執行緒池拒絕策略

拒絕策略提供頂級介面 RejectedExecutionHandler ,其中方法 rejectedExecution 即定製具體的拒絕策略的執行邏輯。
jdk預設提供了四種拒絕策略:

  • AbortPolicy:丟擲異常,中止任務。丟擲拒絕執行 RejectedExecutionException 異常資訊。執行緒池預設的拒絕策略。必須處理好丟擲的異常,否則會打斷當前的執行流程,影響後續的任務執行
  • CallerRunsPolicy:使用呼叫執行緒執行任務。當觸發拒絕策略,只要執行緒池沒有關閉的話,則使用呼叫執行緒直接執行任務。一般併發比較小,效能要求不高,不允許失敗。但是,由於呼叫者自己執行任務,如果任務提交速度過快,可能導致程式阻塞,效能效率上必然的損失較大
  • DiscardPolicy:直接丟棄,其他啥都沒有
  • DiscardOldestPolicy:丟棄佇列最老任務,新增新任務。當觸發拒絕策略,只要執行緒池沒有關閉的話,丟棄阻塞佇列 workQueue 中最老的一個任務,並將新任務加入

相關文章