LWN: 對 edge-trigger 的誤解!

語言: CN / TW / HK

關注了就能看到更多這麼棒的文章哦~

The edge-triggered misunderstanding

By Jonathan Corbet August 5, 2021 DeepL assisted translation https://lwn.net/Articles/864947/ 

安卓 12 beta release 將於今年 5 月首次公佈。當然,每次釋出新版本的時候都宣稱 "安卓歷史上最大的設計改動"。如果不要求使用者重新學習一切,那還配稱自己為一個新的安卓版本嗎?不過,這一歷史性事件並不打算包含一個許多測試者都注意到了的一個改動,這就是一個破壞了大量應用程式的核心 regression 問題。這個問題剛剛被修復,但它是一個很好的例子,說明為什麼如此難於阻止 regression 問題的出現,以及當 regression 發生時,核心專案是如何應對的。

早在 2019 年底的時候,David Howells 對 pipe 管道相關程式碼進行了一些修改來解決一些問題。不幸的是,這項工作引起了核心社群認為最不可接受的那一類 regression:它讓核心的編譯過程變慢了(甚至可能完全停止)!經過廣泛的討論,終於查出來一個對 GNU make job server 的影響,Linus Torvalds 合入了一個 fix,問題就消失了。不久之後,5.5 版本的核心釋出了,核心構建的速度又加快了,人們認為這個問題應該已經解決了。

Not done yet

7 月底,Sandeep Patil 通知到社群,雖然 GNU make 的問題可能已經 fix 了,但這個 fix 產生了一個新問題。他附上了一個 patch 將 Torvalds 的 fix 進行 revert 操作。很明顯,這個 patch 不可能被直接合入,因為核心開發者完全不願意再經歷緩慢的 kernel build 過程,但這引發了對真正問題的調查。

2019 年的 pipe 的 rework 工作,以及後來的 fix 都讓 Torvalds 經歷了意料之外的痛苦,所以他對程式碼的結構和行為都做了一些改動。具體來說,對 pipe 與 epoll_wait()、poll()和 select() 等系統呼叫的工作方式都進行了重要修改。如果希望進行的 I/O 操作不可以允許阻塞的話,這些呼叫就會把程序放到一個等待佇列中去。當情況發生變化時(比如可以開始讀取或寫入資料了),那麼相應的等待佇列上的程序就會被喚醒,它們也就可以繼續進行相應的 I/O 請求了。

2019 年的 fix 改變了完成喚醒的方式。以前,向 pipe 寫東西會無條件地喚醒所有在等待的 reader 角色程序,事實上,在一次系統呼叫中可能會多次喚醒它們。這次 fix 改變了這個行為,變成只在操作開始時 pipe buffer 是為空的情況才會進行 wakeup 喚醒操作。也就是說,如果在寫入的目標 pipe 內已經有待讀取的資料的話,那麼就僅僅只新增新的資料,而不會進行 wakeup 操作。這個改動的邏輯很明確:如果資料已經可以 read 了,那麼 polling 型別的系統呼叫將立即返回,所以只要 pipe 裡有可用的資料了,就不應該有任何程序在繼續等待了。

On the edge

然而,epoll_wait() 有一種叫做 "邊緣觸發"(edge triggered, 或 EPOLLET)的模式,它的行為有點不同尋常。如果有可用的資料的話,申請進行 edge-trigged wait 的程序將不會像 epoll_wait() 那樣立即返回。相反,它會一直 wait 直到情況再次發生變化。至少,在 2019 年的 patch 之前是這樣的行為模式。所以,如果 pipe 驅動程式在資料到達時不再進行 waktup (當已經有資料可用的情況下),在進行 edge-trigged wait 的程序將不會看到 "edge",因此也不會被 wakeup。

我們很有理由懷疑這種問題是否真的會出現。pipe_write() 的上一個版本的註釋就表達了這個觀點:

/* Always wake up, even if the copy fails. Otherwise

  • we lock up (O_NONBLOCK-)readers that sleep due to

  • syscall merging.

  • FIXME! Is this really true?

*/

事實證明,確實會有這種情況。有一些 Android 庫,比如 Realm,哪怕在 epoll_wait() 呼叫之前 pipe 中已經有資料在等待了,也會要依賴 edge-triggered wakeup。顯然這裡的目的是希望一直等待到 pipe buffer 全滿了,然後一次性地讀取到所有的資料。當 5.10 核心跟安卓 12 beta 配合起來時,這些 library 就不再正常了,因此一組應用程式也隨之無法正常工作了。此後,Realm 已經解決了這個問題,但正如 Patil 指出的,很多程序 bundle 在一起就意味著 "等所有應用程式都使用了更新過的 library 的話還會需要不少時間"。如果在 kernel 裡面進行 fix 的話就可以為所有應用程式修復這個問題。

人們似乎普遍認為,這些 library 實際上是誤解了 "edge-triggered" 的含義,並且錯誤地使用了這種模式。正如 Torvalds 所解釋的:

這實際上是對 epoll() 中的 "edge" 的含義的誤解。

edge 並不是指 "有人寫入了更多的資料"。edge 的意思是 "以前沒有資料,現在有資料了"。

而一個 level triggered 事件 也不是 指 "有人寫了更多的資料"。它只是表示 "這裡有資料"。

請注意,edge 和 level 都沒有提到 "更多的資料" 這個資訊。其中一個是指從 "沒有資料"->"有資料 "的這個變化,而另一個只是表示 "有資料"。

然而,pipe 的 edge-triggered 操作的實現方式並沒有做成這個樣子。不出所料,在 Hyrum 法則的作用下,應用程式們開始根據系統實際實現出來的行為來實現了,而不是根據原本定義的語義。epoll() man page 與 Torvalds 的描述一致,介紹了這種阻塞行為(這些無法工作的應用程式所面臨的情況)。如果是很久以前的話,核心開發者們可能只是說一句 “這些庫做錯了”。但現在核心不是這樣處理這類問題的了,因此,Torvalds 繼續說:

但是我們的 regression 是這麼定義的:哪怕是參照文件修改的,或者改成了正確的行為,也不是它可以不被稱為 regression 的理由。

regression 是指某個使用者的應用程式是否可以被觀察到發生了破壞。

基於這種對 "regression" 的解釋,那麼大家就需要 fix 這個問題。事實上,在 7 月底已經完成了一個新的 patch,被合併到 5.14-rc4 中,並被包含在 5.10.56 和 5.13.8 的 stable update 中了。這個補丁並沒有完全恢復之前的行為,具體來說,它在每個寫操作中只會進行一次 wakeup 動作。不過,似乎確實解決了這個問題。

Problem solved?

5.5 核心是在 2020 年 1 月釋出的,當時我們之中很少有人意識到我們後續會面對這個世界會變成什麼樣,像這個比較嚴重的核心 regression 只是又一個意外而已。這個 regression 一直存在了一年半的時間,並在去年 12 月的時候進入了 5.10 long-term-stable release。現在才浮出水面,這說明在某些使用者場景的測試存在遺漏。令人高興的是,它在下一個安卓版本最終確定之前就被發現了。

不過,不確定在這一年半的時間裡是否有任何應用程式已經開始依賴更新過的語義了。事實上,已經有一份報告(來自英特爾的自動測試系統)顯示,在合入了最新的 fix 後,hackbench 這個 benchmark 產生了將近 13% 的效能下降。Torvalds 迴應說,他 "不確定 hackbench 到底有多重要",也許這種效能下降 "可能一點都不重要"。即便如此,他還是釋出了一個新的 patch,提供了更接近於舊有行為的實現,但也僅當 pipe 是用這些 polling 函式族中的其中一個時才有效。如果事實證明 hackbench 的效能下降確實是很重要的問題,那麼至少我們手頭會有一個 fix 已經準備好了。

如果最新的 fix 還破壞了其他東西,那麼核心開發者可能會面臨一個兩難選擇。可能就無法在不導致應用程式被破壞的情況下繼續推進了,這就是為什麼需要儘量早抓到這些 regression。希望我們運氣足夠好,不會有這種兩難選擇拋給我們,並且這個意料之外的故事能終結在這個地方。

全文完

LWN 文章遵循 CC BY-SA 4.0 許可協議。

歡迎分享、轉載及基於現有協議再創作~

長按下面二維碼關注,關注 LWN 深度文章以及開源社群的各種新近言論~