昨天我們用餐廳的比喻了解了同步與非同步的差異。今天,我們要會透過程式碼,重現傳統同步連線在面對併發請求時,伺服器會面對哪些問題
為了專注於觀察底層行為,我們今天先不實際連線 AWS S3,而是用 Java21 模擬真實 Web 伺服器(如 Tomcat 或 Spring MVC 預設狀態)的運作邏輯。
在傳統同步架構中,伺服器會有一個執行緒池(Thread Pool)。當一個請求進來,就從池子裡抓一個執行緒去處理。可以想像成是你是一個餐廳老闆,你可以決定這個餐廳一次有多少個服務生上班,而一個服務生一次只能服務一個客人,不管這個客人拿零錢拿很久,或是廚房做菜做很久,都只能慢慢等,不能做其他的事情,等到服務完這個客人後才能去服務下一個客人。
我們設計以下實驗:
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 印出的時間軸將會完美揭露傳統架構的痛點:
發生了什麼事?從開始執行到結束,處理十個請求花費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如何用少量的執行緒,優雅地解決這個執行緒飢餓的危機!