在這篇網誌文章中,Meta 核心工程師 Chris Down 會探討在 Linux 生產環境中使用 Linux 訊號的隱患,以及開發者為什麼應該儘量避免使用訊號。
訊號是 Linux 系統為回應某些情況而產生的事件。訊號可以由核心傳送給一個程序、由一個程序傳送給另一個程序,或由一個程序向自己傳送。在收到訊號後,程序可以採取行動。
訊號是類 Unix 作業環境的核心部分,基本上從此類系統面世以來就一直存在。訊號連接作業系統中核心傾印、程序生命週期管理等許多核心組件。整體而言,在我們使用訊號的約 50 年期間,其表現一直很好。因此,當有人提出使用訊號進行程序間通訊 (IPC) 可能存在危險時,人們可能會認為這是無稽之談,提出觀點的人無非是想嘩眾取寵。不過,本文旨在闡明訊號造成了生產問題的情況,並提供一些可行的緩解措施和替代方案。
訊號經過標準化,適用範圍廣泛,而且除了作業系統提供的相依性,不需要任何其他相依性,所以可能看起來很吸引。然而,人們很難安全地使用訊號。訊號會作出大量假設,應用程式必須仔細確認這些假設是否滿足其要求;如果不滿足,則應用程式必須仔細採行正確配置。實際上,很多應用程式,甚至包括非常知名的應用程式都不會這樣做,因此以後可能會遇到難以除錯的事件。
讓我們來看看最近在 Meta 生產環境中發生的一件事,以強調使用訊號的隱患。我們會簡單介紹一些訊號的歷史,以及這些訊號如何推動我們達至今天的成就,然後對比目前需求和我們在生產中看見的問題。
讓我們先來回顧一下背景。LogDevice 團隊清理了程式碼基底,移除了不使用的程式碼和功能,而其中一項被停用的功能是一類記錄,用來記錄服務執行的某些操作。此功能成了多餘的存在,沒有任何使用者,因此被移除。您可以在此前往 GitHub 以查看變更詳情。故事去到這裡,一切都很正常。
變更後一段時間並沒有什麼異常,生產繼續穩定推進,正常提供流量服務。幾星期後,有一則回報稱服務節點正以驚人的速度消失。這個問題與新版本推出有關係,但尚未清楚到底是 哪裡 出錯了。是哪些變更造成了問題?
團隊將問題範圍縮小到上述程式碼變更,亦即停用了那類記錄,但為什麼會這樣?那段程式碼有什麼問題?如果您還不知道答案,不妨查看 diff,嘗試找出問題所在。這個錯誤並不明顯,而且大家都有可能犯。
logrotate 基本上是使用 Linux 進行記錄輪替時所用的標準工具。此工具已面世近 30 年,其概念很簡單:透過記錄輪替和清理管理其生命週期。
logrotate 本身並不會傳送任何訊號,因此您在 logrotate 主頁或其文件中不會找到有關訊號的內容,就算有也不多。不過,logrotate 可接受隨機命令並在輪替之前或之後執行。正如 CentOS 中預設 logrotate 配置的基本範例,您可以看到以下配置:
/var/log/cron /var/log/maillog /var/log/messages /var/log/secure /var/log/spooler { sharedscripts postrotate /bin/kill -HUP `cat /var/run/syslogd.pid 2> /dev/null` 2> /dev/null || true endscript }
此工具雖然不太穩健,但我們仍然可以接受,並會假設它可按預期運作。此配置表明,logrotate 輪替列出的任何檔案後,應將 SIGHUP
傳送至 /var/run/syslogd.pid
中包含的 pid,而且這個 pid 應屬於運行中的 syslogd
執行實體。
如果有穩定的公開 API(例如 syslog),這個配置很適用,完全沒有問題,但如果涉及內部,而且其中 SIGHUP
的執行是隨時有變的內部執行細節,那又會如何?
這裡有一個問題:有些訊號(例如 SIGKILL
和 SIGSTOP
)無法在用戶空間被截獲,所以只有一種意義,但其他訊號的語義意義是由應用程式開發人員和用戶所詮釋和編寫。在一些情況下,這主要是學術上的差別,例如 SIGTERM
,人們普遍將其理解為「儘快適當地終止」,但說到 SIGHUP
,則意義不明確許多。
SIGHUP
專為序列線路而設,原本用於表示連線的另一端已斷線。如今,我們當然還是沿用這種做法,所以在現代的同樣情況下仍會傳送 SIGHUP
,亦即在偽終端或虛擬終端關閉時(因此使用 nohup
等工具加以遮罩)。
在早期的 Unix 中,需要執行常駐程式重新載入作業。這項作業通常至少包括在不重新啟動的情況下重新開啟配置或記錄檔案,而訊號似乎是無需相依性就可完成此工作的方法。當然,過去並沒有這種訊號,但由於這些常駐程式沒有控制終端,應該沒有理由接收 SIGHUP
,所以這似乎是可附加搭載的方便訊號,而且沒有任何明顯的副作用。
不過,此計劃有一個小問題。訊號的預設狀態不是「已略過」,而是根據具體訊號而定。例如,程式不必手動配置 SIGTERM
來終止其應用程式。只要沒有設定任何其他訊號處理常式,核心就可免費終止其程式,而且在用戶空間中不需要任何程式碼。非常方便!
然而,不便之處在於 SIGHUP
還有預設行為:立即終止程式。此行為非常適合原來的掛斷案例,在這種情況下可能不再需要這些應用程式,但並不太適用於這種新意義。
如果我們移除有可能向程式傳送 SIGHUP
的所有地方,這種行為當然可以,但問題在於在任何成熟的大型程式碼基底中,這都是件難事。SIGHUP
不像嚴格控制的 IPC 呼叫,您不能為其輕鬆在程式碼基底中執行 grep。訊號可以來自任何地方、任何時候,而且其運行很少會經過檢查(除了最基本的「您是否是此用戶或有 CAP_KILL
」)。最重要的是,我們很難確定訊號的發出位置,而透過使用更明確的 IPC,我們就會知道此訊號對我們沒有任何意義,應該略過。
現在,我想大家可能已經開始猜到發生了什麼。在一個下午,包含上述程式碼變更的 LogDevice 版本正式推出,災難隨之揭幕。起初一切安然無恙,但次日午夜,系統莫名其妙地開始崩潰。這是因為機器的 logrotate 配置中存在以下程式碼段落,這段會向 LogDevice 常駐程式傳送目前未處理的 SIGHUP
(因此導致嚴重錯誤):
/var/log/logdevice/audit.log { daily # [...] postrotate pkill -HUP logdeviced endscript }
在移除重大功能時,非常容易會漏掉一小段 logrotate 配置,這個情況很常見。不幸的是,我們也很難確認是否已一次過移除所有存在痕跡。即使在某些情況下更易確認,但在清理程式碼時也經常會不小心留下殘餘。不過,這樣通常不會造成具破壞性的後果,殘餘程式碼已無作用或不會運行。
理論上,這件事和解決方法都很簡單:不傳送 SIGHUP
,並將 LogDevice 動作分散到不同時間(即不在午夜 12 時準時執行)。然而,我們在這裡應該關注的不僅僅是這一件事的細微差異;更重要的是,這件事必須作為契機勸阻開發人員,讓他們除了在最基本的情況外,在生產中避免使用訊號。
首先,將訊號用作影響作業系統程序狀態變更的機制 是 有根據的。這包括 SIGKILL
等訊號,以及 SIGABRT
、SIGTERM
、SIGINT
、SIGSEGV
、SIGQUIT
等類似訊號的核心預設行為。針對 SIGKILL 等訊號,您無法為其安裝訊號處理常式,這些訊號也不可能按您預期運作;而針對後者,用戶和程式設計人員通常能透徹理解其核心預設行為。
這些訊號的共通點是,在您收到這些訊號後,它們都會在核心中執行,以使終端進入結束狀態。換言之,在您收到沒有用戶空間訊號處理常式的 SIGKILL
或 SIGTERM
後,系統將不再執行用戶空間指示。
終端結束狀態很重要,因為這通常意味著您在逐漸降低目前所執行堆疊和程式碼的複雜程度。其他所需狀態通常會導致複雜程度更高,而且更難推斷,這是因為並行處理和程式碼流程變得更混亂。
您可能注意到,我們沒有提及同樣預設終止的一些其他訊號。以下列出預設終止的所有標準訊號(不包括 SIGABRT
或 SIGSEGV
等核心傾印訊號,因為這些訊號都是合理的):
以下訊號第一眼看起來可能合理,但實則存在一些異常,具體情況如下:
隨著程式的需求改變,在終止訊號中,差不多三分之一在最好的情況下仍存在隱患,而在最差的情況下更已構成危險。更糟糕的是,當用戶忘記對據稱是「用戶定義」的訊號明確執行 SIG_IGN
時,這類訊號遲早仍會帶來災難。即使是無害的 SIGUSR1
或 SIGPOLL
也可能造成事故。
這並不只是熟悉不熟悉的問題。不管您有多了解訊號的運作方式,都極難第一次就編寫出訊號正確的程式碼,因為訊號比表面看起來複雜得多。
程式設計人員一般不會花一整天的時間去考慮訊號的內部運作,因此在實際處理訊號時,他們通常會犯些細微錯。
我所說的問題並不是訊號處理函數安全性這類「微不足道」的情況,這種問題一般只需執行 sig_atomic_t
或使用 C++ 原子訊號柵欄就能解決,大多都很易找出來,而且任何人第一次遭遇地獄般的訊號經歷後就難以忘記這個隱患。更難之處在於複雜程式收到訊號時,推斷其一小部分的程式碼流程。要這樣做,就需要在應用程式生命週期各部分持續明確地考慮訊號(那麼 EINTR
呢?SA_RESTART
是否足夠?如果訊號提早終止,我們應進入什麼流程?我現在有並行處理程式,那又會有什麼影響?),或為應用程式生命週期的某些部分設定 sigprocmask
或 pthread_setmask
,並祈禱程式碼流程永遠不變(在快速發展的環境中,這個想法肯定不切實際)。此時使用 signalfd
或在專用執行緒中運行 sigwaitinfo
會有幫助,但這兩個解決方法有很多邊緣案例,並涉及可用性疑慮,所以我不太建議。
我們相信大部分有經驗的程式設計人員現在都知道,就算是亂開玩笑,要正確編寫執行緒安全程式碼並非易事。如果您認為正確編寫執行緒安全程式碼很難,那麼訊號還要難得多。訊號處理常式必須只依靠擁有原子數據結構的嚴格無鎖程式碼,這樣做有兩個原因:執行的主流程已暫停,我們不知道它持有什麼鎖,以及執行的主流程可能在進行非原子操作。它們亦必須是完全可重入,亦即必須能夠嵌套到自身當中,因為在多次傳送訊號(或 1 次 SA_NODEFER
訊號)時,訊號處理常式可重疊。這就是不能在訊號處理常式中使用 printf
或 malloc
等函數的其中一個原因,因為它們依賴全域 Mutex 進行同步。如果在收到訊號時,您持有該鎖,然後再次呼叫了需要該鎖的函數,則您的應用程式最終會鎖死。這種情況實在很難推斷。因此,很多人在處理訊號時只會編寫以下這樣的程式碼:
static volatile sig_atomic_t received_sighup; static void sighup(int sig __attribute__((unused))) { received_sighup = 1; } static int configure_signal_handlers(void) { return sigaction( SIGHUP, &(const struct sigaction){.sa_handler = sighup, .sa_flags = SA_RESTART}, NULL); } int main(int argc, char *argv[]) { if (configure_signal_handlers()) { /* failed to set handlers */ } /* usual program flow */ if (received_sighup) { /* reload */ received_sighup = 0; } /* usual program flow */ }
這種做法的問題在於,雖然以 signalfd
或其他做法嘗試非同步處理訊號可能看起來很簡單可靠,但有一點忽略了,那就是中斷點與收到訊號後執行的動作一樣重要。例如,假設您的用戶空間程式碼正在執行 I/O 或更改來自核心的物件之中繼數據(如 inode 或 FD),在此情況下,您在中斷時實際上可能位於核心空間堆疊中。例如,在嘗試關閉檔案描述元時,執行緒可能會如下所示:
# cat /proc/2965230/stack [<0>] schedule+0x43/0xd0 [<0>] io_schedule+0x12/0x40 [<0>] wait_on_page_bit+0x139/0x230 [<0>] filemap_write_and_wait+0x5a/0x90 [<0>] filp_close+0x32/0x70 [<0>] __x64_sys_close+0x1e/0x50 [<0>] do_syscall_64+0x4e/0x140 [<0>] entry_SYSCALL_64_after_hwframe+0x44/0xa9
其中,__x64_sys_close
是 close
系統呼叫的 x86_64 變體,用於關閉檔案描述元。在執行期間的這個點,我們要等待備份儲存體更新(也就是這個 wait_on_page_bit
)。由於 I/O 作業通常比其他操作慢幾個數量級,此處的 schedule
可主動提示核心 CPU 排程器,表明我們即將執行高延遲操作(例如磁碟或網絡 I/O),以及排程器現在應考慮尋找另一個程序來排程,以取代目前程序。這樣很好,因為我們得以向核心傳送訊號,示意可以繼續,並選出一個實際使用 CPU 的程序,而不是將時間浪費在必須等待某個需時處理的項目回應後才能繼續進行的程序上。
假設我們向正在運行的程序傳送一個訊號,所發出的訊號在接收執行緒中有個用戶空間處理常式,因此我們會在用戶空間中繼續。終止這種爭用情況的其中一種方式,是讓核心嘗試退出 schedule
,進一步解開堆疊,最後向用戶空間傳回 ESYSRESTART
或 EINTR
的 errno,表示我們被打斷,但我們的關閉進度去到哪裡?現在檔案描述元處於什麼狀態?
既然我們已返回用戶空間,便將運行訊號處理常式。當訊號處理常式退出時,我們會將錯誤依次傳播到用戶空間 libc 的 close
包裝函式,然後再傳播到應用程式,而理論上,應用程式可以對遇到的情況採取一些行動。之所以說「理論上」,是因為針對大多數涉及訊號的這些情況,實在很難知道要做什麼,而且生產中的許多服務都無法妥善處理這裡的邊緣案例。某些應用程式不那麼著重數據完整性,這樣可能沒什麼問題。不過,在 非常重視 數據一致性和完整性的生產應用程式中,便會帶來一個重大問題:核心沒有揭露任何精細的方式來了解相應進展、達到和未達到的成果,以及實際應如何處理相關情況。更糟糕的是,如果 close
傳回 EINTR
,則檔案描述元狀態暫未指定:
“If close() is interrupted by a signal [...] the state of [the file descriptor] is unspecified.”
祝您好運,設法推斷如何在應用程式中安全妥當地處理這種情況吧。通常,即使系統呼叫運作良好,要處理 EINTR
仍然很複雜。此過程涉及不少細微問題,是 SA_RESTART
不足夠的主要原因。並非所有系統呼叫都可重新啟動,而且如果您希望應用程式的每一個開發人員都了解在各個呼叫站點獲取每一個系統呼叫訊號在深層有什麼細微差別,並化解這些差別,等同於要運作癱瘓下來。以下取自 man 7 signal
:
“The following interfaces are never restarted after being interrupted by a signal handler, regardless of the use of SA_RESTART; they always fail with the error EINTR [...]”
同樣,如果您使用 sigprocmask
並期望程式碼流程保持靜態,這也是自找麻煩,因為開發人員通常不會花時間考慮訊號處理的限制,又或者如何產生或保留針對訊號正確的程式碼。在專用執行緒中使用 sigwaitinfo
處理訊號也是如此,最終容易導致 GDB 和類似工具無法為程序除錯。如果程式碼流程或錯誤處理作業有些微不對,可導致錯誤、故障、難以為損毀除錯、鎖死及更多問題,使您不得不頻繁使用事故管理工具。
如果您認為上述有關並行處理、重入和原子性的種種問題已夠糟糕,那麼再加上多執行緒問題簡直就是雪上加霜。鑒於許多複雜的應用程式都透過隱含方式運行單獨的執行緒(例如 jemalloc、GLib 或類似工具的一部分),這個問題顯得尤為重要。其中一些資料庫本身就安裝了訊號處理常式,這又會帶來另一連串的複雜問題。
總之,man 7 signal
對此的說明如下:
“A signal may be generated (and thus pending) for a process as a whole (e.g., when sent using kill(2)) or for a specific thread [...] If more than one of the threads has the signal unblocked, then the kernel chooses an arbitrary thread to which to deliver the signal.”
簡而言之,「針對大多數訊號,核心會將訊號傳送至未使用 sigprocmask
攔截該訊號的所有執行緒」。SIGSEGV、SIGILL 等類似訊號就像是陷阱,讓訊號明確導向有問題的執行緒。人們可能想到使用 tgkill
或 pthread_kill
,但其實這樣也無法將大多數訊號明確傳送到一組執行緒中的單個執行緒。
換言之,只要有一組執行緒,您就不能輕易更改整體訊處理特徵。如果服務需要在主執行緒中使用 sigprocmask
定期攔截訊號,您需要想辦法通知外部的其他執行緒,說明它們應該如何處理。否則,訊號可能會被另一個執行緒吞沒,不再看得見。當然,您可以在子執行緒中攔截訊號,以免發生這種情況。不過,如果子執行緒需要自己處理訊號,即使是使用 waitpid
等基本函數,也會讓事情更複雜。
與其他情況一樣,這些情況並不是在技術上不可解決的問題。不過,人們會放鬆警惕,忽略一個問題:正常運行程式碼所需的同步很複雜,是一個沉重的負擔,而且會促使錯誤、混亂和更糟糕的結果。
訊號在核心中透過非同步方式傳播。如果程序或執行緒的 task_struct
有問題,一旦為其記錄了待處理訊號,就會隨即傳回 kill
系統呼叫。因此,即使訊號不被攔截,也不能保證將其及時傳遞。
即使 及時傳遞了訊號 ,也無法向訊號發出方傳回動作要求的狀態。因此,任何有意義的動作都不應透過訊號傳遞,因為訊號發後即棄,沒有報告傳遞成功與否以及後續動作的實際機制。正如我們在前文看到,如果未在用戶空間中配置,即使貌似無害的訊號也存在危險。
長期使用 Linux 的人肯定都遇到過這樣的情況:本想終止某個程序,卻發現這個程序沒有反應,即使是遇到 SIGKILL
等理應是殺無赦的訊號也是如此。問題出在 kill(1) 這個名稱有些誤導,它的用途不是終止程序,反而只是將要求加入核心中的序列(未指明服務時間),表示有人要求了執行某些動作。
kill
系統呼叫的工作是在核心的任務中繼數據中將訊號標記為待處理,即使 SIGKILL 任務未終止,此操作也能成功執行。具體而言,在使用 SIGKILL
時,核心保證不再執行用戶模式指示,但我們仍必須在核心模式中執行指示,以完成動作(否則可能導致數據損毀)或者釋放資源。因此,即使狀態為 D(不間斷睡眠),我們仍可成功執行此作業。kill 本身不會失敗,除非出現以下情況:您提供無效訊號;您無權傳送相關訊號;您要求將訊號傳送到某個 pid 但它並不存在,因此無法用於將非終端狀態可靠地傳播到應用程式。
避免在處理訊號時遇到麻煩的方法,就是完全不處理訊號。然而,如果應用程式所處理的狀態進程必須對使用 SIGTERM
等情況採取一些行動,最好使用高級別 API(如 folly::AsyncSignalHandler
),其中更直觀地列出了大量問題。
sigprocmask
將訊號範圍限制到程式或執行緒子部分,以減少定期檢查訊號是否正確時需要審查的程式碼數量。請緊記,如果程式碼流程或執行緒策略有變,遮罩可能達不到預期效果。signal(SIGHUP, SIG_IGN); signal(SIGQUIT, SIG_IGN); signal(SIGUSR1, SIG_IGN); signal(SIGUSR2, SIG_IGN);
訊號行為極其複雜,即使使用妥善編寫的程式也很難推斷,而且使用訊號會在應用程式中帶來不必要的風險,但同時其實有其他替代方案可用。一般而言,請勿使用訊號與程式的用戶空間部分通訊。您可改為讓程式以透明的方式處理事件(例如使用 inotify),或者使用用戶空間通訊,此方式可向發出方回報錯誤,且在編譯時支援列舉和論證,例如 Thrift、gRPC 等等。
希望本文可讓您知道,雖然訊號可能看似簡單,但實際上絕非如此。訊號的簡單美學使其成為用戶空間軟件 API,但這也掩飾了一系列不適合現代大多數生產用例的隱式設計決策。
讓我清楚說明一下,訊號是有其適用的情況。在沒有用戶空間組件時,訊號非常適合用於與核心之間就著所需程序狀態(例如要終止程序)進行基本通訊。然而,您很難在第一次時就編寫出訊號正確的程式碼,因為訊號會困在用戶空間中。
訊號經過標準化,適用範圍廣泛,而且沒有相依性,使其看起來可能很吸引,但實際上存在大量隱患,而且這些隱患只會隨著項目的發展徒添煩惱。希望本文能為您提供一些緩解措施和替代做法,幫助您以更安全直觀但不那麼複雜的方式實現目標。
如需進一步了解 Meta Open Source,請瀏覽我們的開放來源網站、訂閱我們的 YouTube 頻道,或在 Twitter、Facebook 和 LinkedIn 上追蹤我們。