iT邦幫忙

2026 iThome 鐵人賽

DAY 16
0
佛心分享-IT 人自學之術

狼人自爆的心路歷程:一個「AI人」的30天自學修煉系列 第 16

Day 16|併發模型(一):Goroutine、Channel 與 select 對上 Java 的執行緒

  • 分享至 

  • xImage
  •  

「三個人在夜裡同時伸手,天亮後只有一份紀錄對得起來——決定誰先落地的,不是誰的手快,是排程。」
——《阿帕契開源審計錄》¹ 卷二·排程篇

幕間
狼窩夜裡,敘事者熟練背出白天向 AI 求得的高深理由,提議刀某個座位。
「附議。」二號秒答。
三號翻了白眼:「理由呢?」——二號淡淡地說:「敘事者講的就是理由。」

第四世走到一半。前面十五個夜晚,我死過三次,每次都以為自己學到了東西,回到第一夜才發現手裡什麼都沒攢下。這一夜輪到我們三隻狼閉眼動作:一個指刀口,一個管女巫的假動作,一個盯預言家的走位——三件事同時發生,卻只有一個結果能寫進天亮後的紀錄。

難的不是誰想殺誰,是順序。三個人心裡認定的目標若不一致,最後刀口落在誰身上?桌上沒有人喊停,也沒有人有權插話(絕發),我們靠的是黑夜給的那條窄縫,把三個獨立的念頭收斂成一個動作。這其實就是我們 2N1P 這半個月一直在練的東西:併發不是「同時做很多事」,是「有能力應付同時湧進來的很多事」。

七號那邊,她的表格今晚多了一欄。原本只有誰、第幾夜、投給誰,現在她在最後補上「理由」兩個字,逐行往回填。她說結論會變,理由不會——一個人今天投三號、明天投五號可以是清白的,但如果他每次講的理由都黏在別人剛講完的那句上,那就不對了。

三條同時發生的夜間動作,一個系統要怎麼決定順序、又怎麼確保它們不會互相踩到?

Java 經典 Thread:一條對一條,作業系統直接排

java.lang.Thread 是作業系統執行緒的 1:1 包裝,每一條的成本要具體看:預設保留約 1MB 的堆疊空間,而且切換要陷入核心模式——存暫存器、換位址空間對應、汙染 CPU 快取與 TLB,一次切換動輒上微秒。排程權在作業系統手上,它會搶佔式地輪流給時間片。這對 CPU 密集的平行運算很合理,一個核心跑一條剛剛好;但要拿來扛十萬條「阻塞在 I/O」的連線就非常浪費——每條連線綁死一份 MB 級資源,其中九成以上時間都在空等封包。所以傳統做法是用執行緒池(ExecutorService)攤平建立成本,再想辦法別讓任務長時間阻塞,程式碼因此被逼成回呼與 future 的碎片。

Go:goroutine 是排給執行緒的工作,不是執行緒本身

goroutine 起始堆疊只有約 2KB,按需成長,切換發生在使用者空間、只需存幾個暫存器,成本落在數十奈秒。Go 執行期把它們以 M:N 的方式排到少量作業系統執行緒上,這就是 G-M-P 模型:G 是 goroutine,M 是作業系統執行緒,P 是帶排程上下文的處理器,數量由 GOMAXPROCS 決定。每個 P 有一條本地執行佇列,某個 P 把自己的佇列清空後,會去別的 P 那裡「偷」一半的 goroutine 過來——這就是工作竊取,它讓所有核心保持忙碌,又不必所有執行緒去搶同一條全域佇列的鎖。goroutine 卡在阻塞的系統呼叫時,M 會連同該 G 脫離,P 立刻交給另一個 M 繼續跑其他 goroutine。開到幾十萬、上百萬條都不誇張。搭配 CSP 哲學——「不要用共享記憶體來通訊,要用通訊來共享記憶體」——channel 是帶型別的管道,select 做多路複用:多個分支裡只有一個就緒的會執行,都沒就緒就阻塞(或走 default)。

Java 25 虛擬執行緒:Loom 把 M:N 帶進 JVM

Project Loom 的虛擬執行緒從 Java 21 起轉正,到 25 已是標準配備。JVM 把大量虛擬執行緒排到一小撮載體執行緒上,虛擬執行緒的堆疊以「延續」(continuation)的形式存在堆積裡。關鍵在於:JDK 把 java.io、NIO 通道、java.util.concurrent 的鎖這些會阻塞的點全部重新接線過,一遇到阻塞就把延續掛起、把載體讓出去。所以你既有的阻塞式程式碼不必改寫就能受益——因為會卡住的地方早就被換成「掛起而非阻塞」了,這是 Loom 最務實的地方。但有兩個洞還沒補完:在 synchronized 區塊裡,monitor 曾經綁的是載體執行緒,虛擬執行緒會被釘住不放——這個釘選已經在 JDK 24(JEP 491)移除,synchronized 現在跟 ReentrantLock 一樣不再卡住載體;呼叫原生(JNI)程式碼時仍然會釘住,這一點還沒解掉。踩到釘選,你的百萬條虛擬執行緒就退化回被那一小撮載體卡死。

Java 結構化並行:StructuredTaskScope 讓 fork 出去的執行緒有始有終

虛擬執行緒解決的是「開得起」的問題,但開了之後呢?三個虛擬執行緒各自 fork 出去,其中一個失敗了,另外兩個要不要連坐取消?誰負責等它們、誰負責收拾?這正是 結構化並行(Structured Concurrency) 要處理的下一層問題——這段筆記整理自 Oracle Java 團隊 Developer Advocate Nicolai Parlog(nipafx)在 TWJUG 的分享《Structured Concurrency in Action》。

這個 API 從 JDK 19 https://ithelp.ithome.com.tw/upload/images/20260922/20183684tCuoY8RwtR.pngJEP 428(Incubator)一路走到 JDK 20 的 JEP 437、JDK 21 的 JEP 453(此時 fork() 已經回傳 Subtask 而非 Future),再經過 JDK 22~24 的 JEP 462480499 反覆修訂 API,直到現行 LTS Java 25 仍是 JEP 505(第五次 Preview),需要加上 --enable-preview 才能使用;Parlog 分享時談到的 JDK 27 也仍是 Preview(JEP 533),距離正式轉正還沒有底定的版本號。這條漫長的 Preview 之路本身就說明了一件事:Java 團隊寧可讓一個併發原語在生產環境外反覆挨打五、六年,也不想把一個容易用錯的 API 焊死進語言核心。

核心概念呼應了 Dijkstra 那句「goto 有害論」:結構化程式設計用 if/loop/procedure 取代自由跳轉的 goto,換來的是「進入點與離開點都寫在同一個地方」的可讀性;結構化並行做的是同一件事,只是把對象換成執行緒——「執行流程分裂成多條並行分支之後,必須在同一個程式區塊裡重新匯合」(Parlog 演講中引用的這句話最早來自 Martin Sústrik 與 Nathaniel J. Smith 的部落格文章,並非 OpenJDK 官方原創,Project Loom 團隊在探索虛擬執行緒的應用場景時採納了這個構想)。做法是把 StructuredTaskScope 包進 try-with-resourcesfork() 出去的每個子任務,其生命週期都被鎖死在這個區塊裡,區塊結束前必須先 join();一旦某個子任務失敗,預設行為(ShutdownOnFailure)會呼叫 Thread.interrupt() 連坐取消其餘還在跑的子任務——但取消是否真的發生,取決於子任務有沒有乖乖檢查中斷旗標(Thread.interrupted())或呼叫本身會拋出 InterruptedException 的阻塞方法;一段死盯 CPU 不檢查旗標的忙迴圈,join() 只能眼睜睜等它自己跑完。

import java.util.concurrent.StructuredTaskScope;
import java.util.concurrent.StructuredTaskScope.Subtask;

// NightIntent models one wolf's independent decision during the dark phase.
final class NightIntent {

    // resolve forks two structured subtasks and requires both to succeed;
    // if either fails, the scope interrupts the other before join() returns.
    static String resolve() throws InterruptedException {
        try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
            Subtask<String> seerCheck = scope.fork(NightIntent::inspectSeer);
            Subtask<String> witchMove = scope.fork(NightIntent::inspectWitch);

            scope.join();          // Waits for both subtasks to finish or one to fail.
            scope.throwIfFailed(); // Rethrows the first failure, if any, as an ExecutionException cause.

            // Reaching here guarantees both subtasks completed successfully.
            return seerCheck.get() + " | " + witchMove.get();
        }
    }

    private static String inspectSeer() throws InterruptedException {
        Thread.sleep(50);
        return "seer-checked-seat-3";
    }

    private static String inspectWitch() throws InterruptedException {
        Thread.sleep(30);
        return "witch-holds-poison";
    }
}

這段程式碼的重點不在語法,而在生命週期保證:seerCheckwitchMove 這兩個虛擬執行緒,無論成功或失敗,都不可能活過 try 區塊的右大括號——這就是「結構化」三個字的具體意義,對照到 Go 的 goroutine,如果沒有 sync.WaitGrouperrgroup.Group 收斂,一個 go func() 是可以在外層函式返回後繼續孤兒般跑下去的;StructuredTaskScope 則是 Java 對「goroutine 不該比它的呼叫者活得更久」這個紀律的語言層級保證。真正決定「join 之後回傳什麼、失敗時丟什麼例外」的,是傳給 scope 的 Joiner——ShutdownOnFailure 是「全部成功才算數」的預設策略,另外還有「只要第一個成功結果」(ShutdownOnSuccess)等內建策略,也可以自訂。

兩種模型畫在一起

1:1 模型讓每個任務吃掉一條作業系統執行緒,切換走核心,貴但直觀;M:N 模型讓執行期自己排,任務一阻塞就換下一個上場,作業系統執行緒維持少量。Go 從第一天就是 M:N,Java 走了二十年 1:1 才用 Loom 補上這一課(早期還需特別防範既有 synchronized 造成的釘選,直到 JDK 24 補齊了最後一塊拼圖)。

package main

import (
	"fmt"
	"time"
)

// NightAction is one wolf's independent intent during the dark phase.
type NightAction struct {
	Actor  string
	Target string
}

// resolveKnife collapses three concurrent intents into a single committed target.
func resolveKnife(knife, feint, watch <-chan NightAction, deadline <-chan time.Time) NightAction {
	// select fires exactly one ready case; the other intents are dropped.
	select {
	case a := <-knife:
		return a
	case a := <-feint:
		return NightAction{Actor: a.Actor, Target: "no-kill (feint)"}
	case a := <-watch:
		return NightAction{Actor: a.Actor, Target: "no-kill (scouting)"}
	case <-deadline:
		return NightAction{Actor: "table", Target: "no-kill (timeout)"}
	}
}

func main() {
	knife := make(chan NightAction, 1)
	feint := make(chan NightAction, 1)
	watch := make(chan NightAction, 1)

	// Three goroutines, three independent decisions, one shared outcome.
	go func() { knife <- NightAction{Actor: "seat-2", Target: "seer"} }()
	go func() { feint <- NightAction{Actor: "seat-5", Target: "witch"} }()
	go func() { watch <- NightAction{Actor: "seat-7-observer", Target: "sheriff"} }()

	committed := resolveKnife(knife, feint, watch, time.After(50*time.Millisecond))
	fmt.Printf("[NIGHT_RESOLVED] actor=%s target=%s\n", committed.Actor, committed.Target)
}

這段程式碼要看三個地方。第一,三個 go func() 各自往自己的 channel 送一個 NightAction,它們是三個獨立執行的 goroutine,送出的順序不保證。第二,resolveKnife 裡的 select 會在多個 case 中挑一個「已經就緒」的執行,其餘的訊號留在 channel 裡沒人收——這正是「三個念頭收斂成一個動作」的機制,語言層面就保證了互斥,不需要你自己加鎖。第三,全程沒有一個被多方讀寫的共享變數:協調是靠把資料送進 channel 完成的,而不是靠大家搶著改同一塊記憶體。換成 Java 經典寫法,會是三個 Thread 加一段對共享「刀口」狀態上鎖的 synchronized,你得自己確保沒有人在別人寫到一半時讀;換成 Java 25 虛擬執行緒,那段程式長得幾乎一樣,差別只在你終於可以一條連線開一條而不必精算數量。

https://ithelp.ithome.com.tw/upload/images/20260922/20183684q92od2xrrN.png

那一夜三個目標最後收斂成一個——刀口給了預言家,另外兩個念頭沒有落地,就像 select 裡沒被選中的分支,安靜地被丟掉。這不是誰讓步,是規則只允許一個動作寫進紀錄。我盯著法官收走那兩個沒落地的念頭,忽然覺得他不只是在跑規則——決定哪個動作寫進紀錄、哪兩個安靜消失,是他本人的手,而那隻手的力氣,遠遠超過一個報時敲槌的角色該有的。好人那邊也沒佔到便宜:他們的白天同樣是絕發,一次一個人,沒辦法像多核心那樣真的平行處理九個懷疑。

七號的「理由」欄那晚開始生效。她往回補的時候發現,二號這三個夜晚的發言有個固定形狀——他每次開口,第一句都是把上一個人的最後一句話重講一遍,然後才接自己的推論。她沒說什麼,只是在他那幾行的理由欄裡都打了個一樣的記號。我坐在對面看得清楚,卻不能提醒他:那個習慣就像一段每次都要先等別人回傳結果才能往下走的程式碼,看起來在參與,其實從沒有自己的起點。

半程走到這裡,村莊的白天還是一次一個人慢慢輪,可是輪過一圈之後,資訊真的有被接住、被記下來。這是這半個月最實在的變化——不是誰變聰明了,是流程本身開始能收斂。

你現在該能:解釋 1:1 與 M:N 兩種執行緒模型的差別,說出 goroutine 為什麼能開到百萬條而 Java 經典 Thread 不行;知道 Java 25 虛擬執行緒想解決的是哪一類問題(大量阻塞在 I/O 的連線),以及它跟 goroutine 在排程上的相似處;也知道虛擬執行緒解決的是「開得起」,而 StructuredTaskScope(目前仍是 Preview API)解決的是「開了之後誰負責收」——兩者是互補而非取代關係。想動手,就用 Go 寫一個 goroutine 加 channel 的生產者消費者,再用 Java 25 的 Thread.ofVirtual() 開一萬條做同一件事,比較記憶體佔用,接著試著把其中一段改寫成 StructuredTaskScope

參考資料與延伸閱讀


¹ 註:本書名為情境設定之虛構文獻,非真實歷史或開源紀錄。


上一篇
Day 15|關聯集合與雜湊碰撞:四種語言怎麼安放身分
下一篇
Day 17|併發模型(二):絕發就是 GIL——Rust 的 Send/Sync 對上 Python 的事件迴圈
系列文
狼人自爆的心路歷程:一個「AI人」的30天自學修煉17
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言