前兩天看到一個推送,介紹epoll的原理的,我覺得是個挺好的例子,可以用來說明「錯誤的軟體架構分析」是什麼樣的。但我這裡不拉仇恨,不放那個鏈接,我只通過一個正面的描述說明一下,正確的軟體架構分析應該是怎麼做的。

很多工程師不能做好(已經存在的軟體的)軟體架構分析(或者建模),核心原因是求「正確」,他們希望他們的表述是沒有錯了,和被分析的對象完全一致。但架構分析不是這樣的,架構是要找到那個軟體「不變」的東西,寧願和被分析對象不完全一致,也要保證首先突出「不變」的部分。但實際上,軟體任何一個地方都是「可變」的,根本沒有「不變」的部分。所以你看,我前面說的東西已經「不嚴謹」了。我追求的是「架構嚴謹」。所以,我其實不是追求「不變」的部分,我追求的是「最難變」的部分。不變-難變-較難變-容易變-自由,構成一個Pattern,你沒有用某種力量去「推」它一下,它看起來就是一個平面,好像一塊硬梆梆的石頭,等你去推它一下,它內在的「Pattern」和「骨架」才露出來了,你纔可以在這個骨架上建你後續的邏輯,那些後續的邏輯纔是穩固的。

邏輯的穩固,是個動態的東西。我舉個簡單的例子幫助理解。比如你有一個硬體A,基於A做了一個軟體B,B上面有一個用戶程序C,那麼這個過程中最難變的是誰?一般情況下,是A的設計,因為A的修改成本最高。所以,A的設計決定了B的介面。但這是初期,到了後期,假設我們有100個用戶用了這個硬體,寫了C1, C2, C3... C100個用戶程序用了這個介面。這個時候,B的介面的控制要素就是當初定義的初期介面了。這個介面本來的控制要素是A,但後來,A的後續版本A1, A2, A3等反過來被它控制了。

這也是架構控制要追的「時機」,所謂聖人求難於其易,謀大於其細。本質也就是這個意思。普通人容易在B設計的初期無所謂,到後面想盡辦法想解決問題,其實已經解決不了了。所以聖人尤難之,才終無難了。你只能求名,就會在初期放棄難,求進展,到難的時候當白蓮花去攻關,但時機已經錯過,你再努力也還是邀名,問題該解決不了,還是解決不了。

所以,邏輯的穩固,本身是個動態的過程,但這個動態具有一定的規律。它會順著已有的邏輯在需求的作用下增強或者減弱,這是有一個連續的「因果」作用過程在驅動的。這個因果根本的驅動力是需求和競爭力。

需求和競爭力表現出來是feature。一個軟體能被產品化,他會包含很多的細節的,部分細節邏輯為feature1服務,部分細節邏輯為feature2服務。如果我們刪除對feature2的需求,和它相關的邏輯(和約束,下同)就都可以刪除。但如果feature 1和feature2之間有複雜的絞連。這我們就做不到了,用戶被迫在「同時選擇f1和f2」和「重建f1,整體放棄整個f1/f2的絞連」之間做出選擇。太多的絞連不能放棄,是整個軟體最終被拋棄的原因。

我前面說那個epoll的介紹文檔寫得不好,就是因為它是平的,失去了「立體」和「重心」。用這種方法分析一個對象,後續的設計就會和無關緊要的東西產生密切的絞連。最終整個軟體就會「死得快」。就算作為一種「學習」,這種學習的知識也是缺乏整理,無法有效利用的。

這其實是架構設計「不為天下先」這個策略的原因。每個設計,必然產生新的「約束」。加一個狀態機管理,為了保證每次躍遷在執行上是原子的,就要加上鎖的使用約束。加上鎖的使用約束,會對線程的使用加上約束,對線程的使用加上約束,就對對外介面的提供產生約束,對對外介面產生約束就會對應用的業務模型產生約束,對業務模型產生約束就會對休眠過程產生約束,對休眠過程產生約束就會對業務遷移產生約束……約束越來越多,後面什麼設計都不用加了。我們我們每次設計新的特性,增加新的邏輯鏈,都希望不產生新的約束,而是「復用」已有的約束,這就叫「不為天下先」。把設計做立體,目的就是希望當某個特性引入的約束太多的話,我們可以整體放棄它(這种放棄包括限制它的功能範圍,出分支版本等),從而保護我們的整個軟體的生存能力。不能放棄部分東西的個體,結果就是死。人類、生物為什麼要發展出族羣發展,個體死亡的演進模式?這個原理是一樣的。沒有放棄,要不沒有發展,要不沒有生存。

回到epoll和select的問題。我們要看這兩個東西,不是看它的實現的,我們看的是它們在整個邏輯鏈中,已經建立的約束和帶來的利益是什麼,這才會看見它們的架構。

select包含兩個介面select和pselect,兩者只有一些細節上的差異,我們忽略這種小差異。從介面上說,select的核心是定義一組文件fd(簡稱fds),如果fds中某個fd的狀態符合要求(比如變得可讀,可寫,或者有異常等),select就從等待中返回。

我們先不看內核怎麼實現它的,我們至少可以先從邏輯上猜一下:這個東西要有一個基本的性能保障,靠任何一個fd的主動變化都能喚醒select這個系統調用引起的等待。它唯一的手段是讓這個系統調用等在一個wait_queue上,然後讓這些fd的backend在文件內容更新(這肯定是個IO線程的變動(註:這裡的線程包括中斷等任何非同步執行的序列))的時候signal這個wait_queue。

這個設計可以帶來的用戶優勢也是明顯的:它的核心是用執行任務調度取代了線程調度。比如說吧,你有10個fd要監控處理。如果誰有事就要立即處理,你要不輪詢,但輪詢就會佔用無效的輪詢時間。如果你不想輪詢,就要做同步等待,但同步等待要保證每個fd都能立即響應,你需要10個線程,每個等待在其中一個fd上面(否則前面的在等待,就無法處理後面的fd的消息)。

而線程是有成本的,如果你只有兩個核,創建10個線程。這毫無意義。某個fd的信息處理了一半,時間片用完了,切換到另一個線程繼續執行,這個切換對你毫無價值,只是增加成本。不如只創建兩個線程,每個只處理一半的fds,處理完一個請求,再處理下一個。要做這種事,只有調度器能給你搞定,在用戶態你自己是搞不定的。這樣,你就需要一次等待在多個fd上,哪個fd就緒了,你就處理那一個,處理完了再考慮下一個。這樣,所謂「多路復用」的select就有它的價值了,你沒有任何其他手段可以做到這個。

我們看到了介面限制和利益考量,就可以對這個介面的演進有所判斷,也可以知道我們應該在什麼時候使用它了。其他的細節,都是枝葉而已。

再看看epoll,從介面表面的佈置看,兩者幾乎是一樣的,只是select用數組表示fds,epoll用另一個fd(epollfd)來表示fds。我簡單想像一下:把原來select的介面大部分封裝在epoll的介面上,不會太大的困難。比如把select變成自動創建一個epollfd,然後用epoll_ctl把數組中的fds設置進去,然後調epoll_wait()就可以了。

所以,兩者本質是一個東西,區別僅僅是select的fds是每次等待都要設置進去,epoll的fds是一開始設置好,等待的時候不需要重新指定。這可以想像epoll在極端情形下會有三個優勢:

  1. 如果我們固定等待一個數量很大的fd集合,每次select的成本會很高,因為要把這樣一個列表每次拷貝到內核需要成本,而epoll只在初始化的時候需要處理
  2. 可以給每個fd設定要等待的類別,而不是分成三個組獨立管理
  3. 由於每次fd更新了我們都要返回給用戶態,要找到這個更新的fd,需要通過FD_ISSET掃描整個fds數組,這在fds數量很多的時候,也是不可忍受的成本

功能上的另一個比較明顯的改進是epoll增加了邊緣觸發和電平觸發的概念。對使用者的利益看起來是:可以讓代碼邏輯變得更規整,因為你沒有把數據處理完,你可以回去繼續epoll,讓它繼續走循環,這時如果其他fd就緒了,我們可以有機會優先處理那個fd。這樣不會因為一個fd餓死所有其他的fd。但這個不是關鍵問題,因為它很容易在用戶循環中通過增加一個「未完結fd cache」實現。

這樣,我們基本上可以形成這樣一種判斷:epoll是select的升級版,一般情況下,或者fds特別多的情況下,應該首先用epoll。至於select,看你的平臺支持情況,介面細節匹配情況再進行選擇好了。

有了這些準備,我們才值得去看內核的實現上的不同。如果按前面這個判斷,我會認為這兩個介面應該復用同一個內核實現。但實際上不是,它們互相是獨立的。一個實現在fs/select.c中,一個實現在fs/eventpoll.c中。但兩者都是以fd的file->f_ops->poll函數為基礎。因為poll回調是一個有大量實現者的介面,這些實現者就會成為這裡的控制要素。分析它的介面我們就能看到內核可能的走向:

poll介面的語義要求是這樣的:

unsigned int poll(struct file *file, poll_table *wait);

它要求每個實現者都主動調用下面這個函數:

static inline void poll_wait(struct file *file, wait_queue_head_t * wait_address, poll_table *wait);

它要求poll的實現者在這個函數中返回的時候,檢查對應文件實體的狀態,然後返回是可讀可寫還是錯誤。

其中wait_address由實現者自己定義,如果自己有數據了(比如硬體發出中斷了),就signal這個wait_address,讓它不要等待。

這個介面非常Tricky。你想像一下你來實現select或者epoll,你可以怎麼用?

如果poll_wait就是在wait_address上等待,你wait就停在一個fd上了,你怎麼玩其他fd的遊戲?

我唯一能想到的用法是這樣:把所有fd做成一個列表,先全部調一次所有fd的poll(),通過修改wait保證poll_wait()不會等待,只是收集它們的wait_address。把這些wait_address組織成一個完整的wait_queue。然後我纔等在這個wait_queue上,之後,任何一個fd發了signal,我被釋放了,我再去定位對應的fd,再調一次它的poll,更新它的狀態,再從系統調用中返回。

這中間有很多優化策略,比如從signal快速定位fd,掃描poll的時候根據上次的結果決定是否需要調用它的poll,這些就是枝葉了。

這個邏輯,無論select還是eventpoll都越不過去,我們再確認一下具體的實現。判斷實現上和我們前面的判斷一致了,這樣我們就完成了對這兩個API的架構認知了。


推薦閱讀:
相關文章