關於
本文說明執行緒信號,這是一種 Zircon 核心機制,用於實作執行緒暫停和終止作業。執行緒信號與物件信號無關。
目標對象是核心開發人員,以及任何想瞭解核心中暫停和終止作業運作方式的人。
暫停和終止是要求
暫停和終止是可以對執行緒執行的作業。這兩項作業都是非同步作業,呼叫端必須等待作業完成。在核心中,這些作業會實作為 Thread 結構體的執行個體方法:
Thread::Suspend:要求執行緒暫停執行,直到透過 Thread::Resume 繼續執行。暫停功能可用於實作偵錯工具。暫停後,執行緒的暫存器狀態可在恢復前讀取/寫入。這項作業會透過 zx_task_suspend() 向使用者模式公開。
Thread::Kill:要求執行緒自行終止。這項作業不會直接向使用者模式公開。也就是說,嘗試 zx_task_kill() 執行緒會發生錯誤。不過,這項作業會透過自願和非自願的程序刪除間接公開。
請注意,這兩項作業都稱為要求。呼叫端會要求目標暫停執行,或在終止的情況下終止執行。呼叫端無法強制暫停或終止目標。雖然目標無法拒絕要求,但可以延遲行動,直到適當的時間和地點。這是設計的關鍵要素。
如要瞭解為何這些作業是要求,請考慮強制終止或暫停執行緒的替代方案。如果執行緒在持有資源 (例如互斥鎖) 時遭到強制終止,則在遭到刪除前,將無法釋放資源。您可能會遇到記憶體流失、永久鎖定、資料結構損毀等各種問題。
我們將終止和暫停模擬為只能由目標執行緒執行的要求,讓目標在停止執行前 (暫時或永久),釋出資源並執行任何必要的清理作業。
安全點
在介紹如何發出終止和暫停要求之前,我們先來談談終止執行緒的安全性。
執行緒一律可以安全地暫停或終止執行,那就是核心的「邊緣」,也就是從核心返回使用者模式之前。在返回使用者模式之前,執行緒會展開呼叫堆疊,執行任何 RAII 物件的解構函式。當它到達邊緣並即將返回使用者模式時,核心堆疊上將不會留下任何內容。執行緒可以在這裡安全地暫停或終止執行。
具體來說,執行緒有兩個安全點可以暫停或終止。分別是從系統呼叫返回使用者模式之前,以及從例外狀況/錯誤/中斷處理常式 (簡稱例外狀況處理常式) 返回使用者模式之前。
請注意,例外狀況處理常式不只會在使用者模式中執行時叫用。在核心模式下執行時,也可以叫用這些函式。返回核心模式時,暫停或終止並不安全,因為外部核心模式內容可能仍保留資源。換句話說,只有在從使用者模式情境觸發時,例外狀況處理常式才是安全點。
傳送訊號
因此,我們知道終止和暫停只是要求,由目標執行緒決定何時以及如何執行要求。我們也知道,執行緒暫停或終止自身的唯一安全位置是在核心邊緣,就在返回使用者模式之前。執行緒信號在其中扮演什麼角色?
執行緒信號是要求暫停和終止的機制。每個 Thread 物件都有一個欄位,內含一組已宣告的信號。暫停和終止分別有 THREAD_SIGNAL_SUSPEND 和 THREAD_SIGNAL_KILL 兩個位元。
如要要求暫停或終止執行緒,請在目標 Thread 物件上設定適當的位元,然後根據目標狀態,以某種方式觸發該物件,確保其及時達到安全點。在保留目標執行緒的鎖定時,系統會使用原子 OR 設定位元。確切的戳記類型取決於目標執行緒的狀態:睡眠/封鎖、暫停或執行中。請注意,休眠/封鎖狀態有兩種:可中斷和不可中斷。我們將著重於可中斷。如果目標遭到封鎖且無法中斷,傳送者只需設定位元,執行緒自行喚醒後,信號就會在下一個安全點執行。
休眠或封鎖
如果目標執行緒處於休眠或封鎖狀態,則根據定義,該執行緒並未執行,但位於核心中。由於只有執行中的執行緒可以檢查信號,因此我們必須喚醒或解除封鎖。當執行緒解除封鎖或喚醒時,系統會提供 zx_status_t。這個值通常是 ZX_OK 或 ZX_ERR_TIMED_OUT。不過,像這樣提早喚醒執行緒時,我們會使用特殊的 zx_status_t 值,在終止作業時使用 ZX_ERR_INTERNAL_INTR_KILLED,在暫停作業時使用 ZX_ERR_INTERNAL_INTR_RETRY。
當執行緒喚醒/解除封鎖時,會看到 zx_status_t 結果,並開始退出核心,展開其堆疊。一般來說,任何核心函式只要傳回這兩個特殊值之一,就會導致呼叫端立即傳回,並傳播該值。
最後,當堆疊展開時,執行緒會位於邊緣,也就是安全點。在返回使用者模式之前,執行緒會再次檢查信號,並呼叫 arch_iframe_process_pending_signals() 或 x86_syscall_process_pending_signals() 採取行動。這兩者最終都會進入 Thread::Current::ProcessPendingSignals(),該函式會先處理終止,再處理暫停,並持續執行,直到沒有信號為止。
關於封鎖案例,值得注意的是,如要解除封鎖執行緒,必須鎖定該執行緒所屬的整個優先順序繼承鏈結。如果傳送者無法取得所有鎖定,就會退回並重試。
暫停使用
就像休眠/封鎖的情況一樣,執行緒必須繼續執行才能終止。如果是終止,執行緒會透過 ZX_ERR_INTERNAL_INTR_KILLED 解除封鎖,並展開至返回使用者模式之前,然後在使用者模式中對信號採取行動。
執行中
目標執行緒可能正在執行使用者程式碼或核心程式碼。如果正在執行使用者程式碼,就必須強制進入核心,檢查 Thread 結構的信號欄位。如果執行的是核心程式碼,我們就必須相信它會在合理的時間範圍內檢查待處理的信號。
傳送者無法得知目標是處於核心模式還是使用者模式,因此在任一情況下的行為都相同。傳送者會將處理器間中斷 (IPI) 傳送至目標目前執行的 CPU。中斷處理常式的工作之一,是檢查並視需要處理待處理的信號。
如果處理常式是在使用者情境中叫用,也就是 CPU 在中斷時處於使用者模式,則可以安全地暫停/終止,且處理常式會呼叫 arch_iframe_process_pending_signals()。
不過,如果處理常式是在核心環境中叫用,則處理常式不會執行任何動作,因為它無法得知執行緒在被中斷時的狀態。在這裡暫停/終止並不安全。而是會返回叫用它的核心情境,並依賴這個外部情境最終注意到信號並達到安全點。
您可能會想知道,是否真的需要 IPI。有兩種情況特別重要。第一種情況是目標執行緒在使用者模式下執行,且不會自行進入核心。在負載較輕且沒有其他中斷流量的系統上,執行緒可能長時間不會進入核心,或在無限迴圈的情況下永遠不會進入核心。在這種情況下,我們需要 IPI,確保目標執行緒能及時觀察及處理任何待處理信號。第二種情況是目標執行緒在核心中執行長時間執行的作業,但未檢查待處理的信號。這種情況很少見,但確實存在。最典型的例子是透過 zx_vcpu_enter() 執行訪客 OS。中斷會導致 VMEXIT 返回主機核心,以便檢查待處理信號並解除。
要點總整理
我們來看看一個例子,瞭解這一切如何運作。假設執行緒 A 正在暫停執行緒 B,因為 B 正在執行 zx_port_wait()。視作業執行的確切時間而定,我們可能會遇到幾種不同的情況。我們會簡要說明各個情境。
情境 1:在系統呼叫前暫停,以使用者模式執行
執行緒 A 在執行緒 B 開始 zx_port_wait() syscall 之前發出暫停。執行緒 B 仍處於使用者模式,且正在執行。執行緒 A 會設定執行緒 B 的 THREAD_SIGNAL_SUSPEND 位元,並向執行緒 B 目前的 CPU 發出 IPI。執行緒 B 的 CPU 接受中斷並呼叫中斷處理常式。在返回使用者模式之前,執行緒 B 會檢查待處理信號。看到 THREAD_SIGNAL_SUSPEND 已設定,就會自行暫停。以下是執行緒 B 呼叫堆疊的草圖:
suspend_self()
interrupt_handler()
---- interrupt ----
user code
之後,執行緒 B 恢復執行時,會返回使用者模式,就像什麼都沒發生一樣。
情境 2:在系統呼叫期間暫停,然後封鎖
執行緒 B 進入核心執行 zx_port_wait() syscall 後,執行緒 A 發出暫停指令。執行緒 B 正在執行核心程式碼,尚未遭到封鎖。與情境 1 相同,執行緒 A 發出 IPI,導致執行緒 B 檢查待處理信號:
interrupt_handler()
---- interrupt ----
Semaphore::wait()
PortDispatcher::dequeue()
sys_port_wait()
syscall_dispatch()
---- syscall ----
vdso
zx_port_wait()
user code
下圖中的框架名稱是 Rust 實作的名稱。C++ WaitQueue::BlockEtc() 和其他項目則是相同程式碼的精簡包裝函式。
不過,這次中斷處理常式會發現它是在核心情境 (而非使用者情境) 中叫用,因此不會自行暫停。而是返回叫用它的核心情境。執行緒 B 抵達 zx_port_wait() 作業的核心,如果沒有可用的封包,就會在此處遭到封鎖。執行緒 B 發現沒有可用的封包,並準備封鎖:
WaitQueue::block_etc_preamble()
Semaphore::wait()
PortDispatcher::dequeue()
sys_port_wait()
syscall_dispatch()
---- syscall ----
vdso
zx_port_wait()
user code
在封鎖之前,它會檢查待處理的信號,並發現它已被要求暫停。如果設定終止位元,Thread::check_kill_or_suspend_signal() 會傳回 ZX_ERR_INTERNAL_INTR_KILLED;如果只設定暫停位元,則會傳回 ZX_ERR_INTERNAL_INTR_RETRY。執行緒 B 不會遭到封鎖,而是會傳回 ZX_ERR_INTERNAL_INTR_RETRY,且呼叫堆疊會展開至邊緣,然後返回使用者模式:
WaitQueue::block_etc_preamble() ZX_ERR_INTERNAL_INTR_RETRY
Semaphore::wait() |
PortDispatcher::dequeue() |
sys_port_wait() |
syscall_dispatch() V
---- syscall ----
vdso
zx_port_wait()
user code
執行緒會在這裡檢查待處理的信號,並自行暫停。執行緒恢復後,會返回使用者模式 (vDSO),狀態結果為 ZX_ERR_INTERNAL_INTR_RETRY。vDSO 具有特殊邏輯,可處理傳回 ZX_ERR_INTERNAL_INTR_RETRY 的系統呼叫,只要使用原始引數重新發出系統呼叫即可:
suspend_self() ZX_ERR_INTERNAL_INTR_RETRY
syscall_dispatch() |
---- syscall ---- | A
vdso |______|
zx_port_wait()
user code
情境 3:在核心中遭到封鎖時暫停
執行緒 B 進入核心並遭到封鎖,等待通訊埠封包時,執行緒 A 發出暫停指令。執行緒 A 發現執行緒 B 遭到封鎖,因此使用 ZX_ERR_INTERNAL_INTR_RETRY 值解除封鎖執行緒 B。從這裡開始,行為與情境 2 相同。呼叫會返回使用者模式,並由 vDSO 重試:
blocked ZX_ERR_INTERNAL_INTR_RETRY
WaitQueueBase::block_etc_postamble() |
Semaphore::wait() |
PortDispatcher::dequeue() |
sys_port_wait() |
syscall_dispatch() |
---- syscall ---- | A
vdso |______|
zx_port_wait()
user code
情境 4:解除封鎖後、從核心返回前暫停
執行緒 B 遭到封鎖,等待通訊埠封包,封包抵達後,解除封鎖 (使用 ZX_OK):
blocked ZX_OK
WaitQueueBase::block_etc_postamble() |
Semaphore::wait() |
PortDispatcher::dequeue() V
sys_port_wait()
syscall_dispatch()
---- syscall ----
vdso
zx_port_wait()
user code
當執行緒 A 發出暫停指令時,執行緒 B 現在會向使用者模式展開。執行緒 A 設定位元,發現執行緒 B 標示為執行中,因此傳送 IPI。與「在系統呼叫前暫停」的情況類似,中斷處理常式會執行:
interrupt_handler()
---- interrupt ----
PortDispatcher::dequeue()
sys_port_wait()
syscall_dispatch()
---- syscall ----
vdso
zx_port_wait()
user code
不過,這次不會檢查待處理信號,因為處理常式中斷的是核心環境,而非使用者環境。處理常式完成後,執行緒 B 會繼續展開。最後,執行緒 B 抵達邊緣,即將從系統呼叫返回使用者模式。此時,它會檢查待處理的信號,看到 THREAD_SIGNAL_SUSPEND 後,便會自行暫停:
suspend_self()
syscall_dispatch()
---- syscall ----
vdso
zx_port_wait()
user code
恢復後,系統會返回使用者模式,並顯示解除封鎖的狀態結果 (ZX_OK):
syscall_dispatch() ZX_OK
---- syscall ---- |
vdso V
zx_port_wait()
user code
重點回顧
重點如下:
您無法強制暫停或終止執行緒。只能要求暫停或終止。
執行緒信號是用來要求執行緒暫停或終止的機制。
執行緒只能在核心中的特定點暫停或終止執行。具體來說,執行緒只有在不持有任何資源 (例如鎖定) 且即將從核心模式返回使用者模式時,才能暫停或終止執行。
為了保持回應,長時間執行的核心作業必須定期檢查待處理的信號,並在設定任何信號時傳回。