iT邦幫忙

2026 iThome 鐵人賽

DAY 4
0
Software Development

學校沒教的後端生存指南:30 天打造非同步 S3 微服務,部署 K8s 實現 HA 架構系列 第 4

[ Day 4 ] 傳統的極限:實作同步連線與執行緒飢餓(Thread Starvation)

  • 分享至 

  • xImage
  •  

昨天我們用餐廳的比喻了解了同步與非同步的差異。今天,我們要會透過程式碼,重現傳統同步連線在面對併發請求時,伺服器會面對哪些問題

為了專注於觀察底層行為,我們今天先不實際連線 AWS S3,而是用 Java21 模擬真實 Web 伺服器(如 Tomcat 或 Spring MVC 預設狀態)的運作邏輯。

同步連線實驗環境:模擬伺服器連線池

在傳統同步架構中,伺服器會有一個執行緒池(Thread Pool)。當一個請求進來,就從池子裡抓一個執行緒去處理。可以想像成是你是一個餐廳老闆,你可以決定這個餐廳一次有多少個服務生上班,而一個服務生一次只能服務一個客人,不管這個客人拿零錢拿很久,或是廚房做菜做很久,都只能慢慢等,不能做其他的事情,等到服務完這個客人後才能去服務下一個客人。

我們設計以下實驗:

  1. 伺服器資源: 建立一個只有 3 個執行緒的 Thread Pool(代表我們有限的伺服器資源,餐廳只有三個服務生)。
  2. 併發請求: 瞬間發起 10 個檔案上傳請求(同時十個客人走進餐廳裏面)。
  3. I/O 延遲: 假設上傳檔案到 S3 需要耗時 2 秒鐘(模擬網路傳輸的阻塞,等待餐點需要兩秒)。
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.time.LocalTime;
import java.time.format.DateTimeFormatter;

public class SyncStarvationDemo {
    // 取得當前時間的輔助方法,方便觀察時間軸
    private static String now() {
        return LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss"));
    }

    public static void main(String[] args) throws InterruptedException {
        System.out.println("[" + now() + "] 伺服器啟動,連線池容量: 3");
        
        // 1. 模擬 Web 伺服器的 Thread Pool,容量只有 3
        ExecutorService threadPool = Executors.newFixedThreadPool(3);

        // 2. 瞬間湧入 10 個使用者的上傳請求
        for (int i = 1; i <= 10; i++) {
            final int taskId = i;
            threadPool.submit(() -> {
                String threadName = Thread.currentThread().getName();
                System.out.println("[" + now() + "] " + threadName + " 開始處理請求 " + taskId);
                
                try {
                    // 3. 模擬同步呼叫 S3 API:執行緒在這裡「死等」 2 秒
                    Thread.sleep(2000); 
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                
                System.out.println("[" + now() + "] " + threadName + " 請求 " + taskId + " 上傳完成 V");
            });
        }

        threadPool.shutdown();
        threadPool.awaitTermination(1, TimeUnit.MINUTES);
        System.out.println("[" + now() + "] 所有請求處理完畢");
    }
}

結果分析

執行這段程式碼時,Console 印出的時間軸將會完美揭露傳統架構的痛點:
https://ithelp.ithome.com.tw/upload/images/20260830/201838643s7OOctKfY.png

發生了什麼事?從開始執行到結束,處理十個請求花費9秒的時間,有十個客人同時走進餐廳,但只有三個服務生上班,所以thread-1 到 thread-3 接客後,就卡在 Thread.sleep(2000) 模擬的網路 I/O 上。在這漫長的 2 秒內,伺服器的 CPU 其實閒得發慌(根本沒有在做運算),但排在後面的請求 4 到請求 10,只能在門外排隊乾等,這就是一種執行緒飢餓 (Thread Starvation)!每梯次只能處理三個客人,且一次都要兩秒的時間,十個客人至少需要4個梯次才能處理完,所以需要4x2=8秒以上的時間

如果這是在正式的生產環境中,使用者的畫面就會一直轉圈圈;一旦等待排隊的時間超過了瀏覽器Timeout限制,使用者就會收到無情的 502 Bad Gateway

多請一點服務生來增加 Thread Pool大小不就好了嗎?

思考一下,如果把程式碼第17行Executors.newFixedThreadPool(3) 改成 3000 會發生什麼事嗎?

答案是看情況,如果你的服務所要面對的請求量本身就不多,例如就算是餐廳尖峰吃飯時間,最多也只會有1.200人來同時擠進餐廳要用餐,那摩把thread pool調大或許沒問題,不過如果突然有1000或100000人呢?在 Java 中,每一個 Thread 預設會佔用約 1MB 的記憶體空間。如果你為了應付一萬個併發請求而開了一萬個執行緒,光是維持這些執行緒就會吃掉 10GB 的記憶體!更何況作業系統在成千上萬個執行緒之間頻繁切換 (Context Switch) 時,會把 CPU 資源徹底耗盡。系統可能會整個崩潰

實務上的同步連線長什麼樣子?

在 Java 生態中,最經典的同步連線工具就是 Apache HTTP Client。當呼叫外部 API 或連線 S3 時,通常需要先建立連線在宣告request:

CloseableHttpClient httpClient = HttpClients.createDefault();
HttpGet request = new HttpGet("https://s3.amazonaws.com/my-bucket/file");
// ⚠️ 程式走到這行時,執行緒會被完全卡死 (Blocked),直到 S3 回傳結果
CloseableHttpResponse response = httpClient.execute(request); 

問題在哪裡呢?一旦底層的thread pool已經滿了,排隊階段時httpClient.execute() 就是高併發系統的死穴。沒辦法正常建立,導致timeout或是太多的等待thread而造成OOM!

換言之,如果我們的目的需求已經定位在打造真正的高可用與高併發微服務,拋棄傳統同步連線是我們必須跨出的第一步。明天,我們將來學習非同步架構的Netty如何用少量的執行緒,優雅地解決這個執行緒飢餓的危機!


上一篇
[ Day 3 ] 為什麼伺服器會卡死?圖解同步、非同步與阻塞的愛恨情仇
下一篇
[ Day 5 ] 非同步救星:初探 Netty 與 Event Loop 事件驅動模型
系列文
學校沒教的後端生存指南:30 天打造非同步 S3 微服務,部署 K8s 實現 HA 架構5
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言