機器被遺忘的那三分之一
暫且爬回階梯下方,看看我們蓋出來的一切。我們設計了一條資料路徑,把它接進一條管線,然後花了一整級,把記憶體之牆藏在一塊快取後面,好讓處理器幾乎永遠不會餓著。這所有的努力都朝內,指向 CPU 與它的記憶體。但一顆只會運算、卻不跟任何東西說話的處理器,是一座沒有門的火爐——它燒得很旺,卻溫暖不了任何房間。任何電腦的第三大部分,就是輸入輸出:那些鍵盤、螢幕、磁碟與網路,機器正是透過它們才真正遇見了這個世界。
輸入輸出背了個壞名聲,被當成無聊的水管工程——你會想跳過它,趕快回到那些聰明的管線把戲。這種直覺剛好錯反了。對極其大量的真實程式而言,CPU 一眨眼就把它的活幹完,然後就等:等一個檔案載入、等一筆資料庫紀錄抵達、等一個封包越過網路。我們那麼用心打磨的處理器,大半輩子都在跺著腳乾等。所以這一整級誠實的標題雖然讓人不舒服,卻是真的:輸入輸出往往才是真正的瓶頸,而核心裡再多的聰明,也救不了一個正卡在磁碟上等待的程式。
快有兩種:延遲與吞吐量
當我們說一個輸入輸出裝置「快」,其實悄悄夾帶了兩個完全不同的問題,而把它們搞混,正是初學者的經典錯誤。第一個是延遲(response time,反應時間):一個請求要花多久,從開口問到拿到答?第二個是吞吐量(throughput,也叫頻寬):當系統忙碌時,它每秒能完成多少個請求、或多少位元組?一個裝置可以在其中一項上出色得發亮,在另一項上卻糟得可怕,所以你永遠得問:你的程式真正在乎的是哪一個?
想想一輛宅配廂型車對上一列貨運火車。廂型車載著一個小包裹,二十分鐘就到——延遲漂亮極了,你很快就拿到東西。火車這趟要跑八小時,但它一次拉一萬個包裹——延遲糟透了,吞吐量卻輝煌無比。如果是你正在等一封急件,你要那輛廂型車。如果一座倉庫得在週五前出貨一百萬個箱子,火車輕鬆勝出。沒有哪一個單純地「比較快」;它們是在不同的軸上快。一顆硬碟就是那列火車(單獨一次讀取爬得很慢,但它能成批地把資料串流出來);一次小小的快取命中則是那輛廂型車(瞬間就好,但它只搬得動寥寥幾個位元組)。
這兩者甚至會互相交換。提高吞吐量常見的辦法是批次處理——等到你蒐集了許多請求,再用一次又大又有效率的橫掃一併處理。但每一個你為了湊滿批次而扣住的請求,都被迫等得更久,於是它的延遲變糟了。這股張力貫穿整個這一級:DMA 引擎、磁碟排程器、網路卡,做的全是這筆同樣的交易,付出一點延遲去換來大量吞吐量。鮮少有單一一個「快」的設定;只有對你的工作負載而言對的設定。
CPU 究竟怎麼搆得到一個裝置
在能談速度之前,我們得先把水管接好:一顆只懂得對記憶體位址做載入與儲存的處理器,究竟怎麼去指揮一顆磁碟或一塊螢幕?在 CPU、記憶體與裝置之間搬運資料的共用線路,叫做匯流排——歷史上是一組大家輪流使用的線。匯流排很民主,卻很慢:一次只能有一段對話,就像每位賓客都得擠過的單一道門。現代機器大多用點對點的互連取而代之——在兩個端點之間的私有專屬車道——其中最重要的就是 PCI Express,你的顯示卡與固態硬碟插上去的那種高速序列連結。
現在來看聰明的部分,它重用了你早已知道的東西。每個裝置都由一塊小小的裝置控制器晶片掌管,而那個控制器對外露出寥寥幾個暫存器——一個命令暫存器、一個狀態暫存器、一個資料暫存器。叫做記憶體映射輸入輸出的把戲,把那些裝置暫存器指派到你的程式所用的同一個位址空間裡的真實位址上。要啟動一次磁碟讀取,CPU 只要把一個命令儲存到位址 0x1F0;要查磁碟是否做完了,它就從狀態位址載入。沒有發明任何新指令——你老早以前就見過的載入與儲存,加上穿過記憶體映射輸入輸出區的位址轉換,就是整個介面。
等候慢裝置的三種辦法
核心的難處說白了就是:CPU 每秒跑數十億次運算,而磁碟以毫秒為單位作答——中間隔著數百萬個週期的鴻溝。一顆快處理器該如何等候某個如此緩慢的東西,又不至於白白浪費掉自己的一生?答案恰好有三個,它們組成一道聰明程度遞進的階梯,正是這一級接下來要探的:輪詢、中斷,以及 DMA。每一個都消除掉一種不同的浪費,三者合起來,就解釋了為什麼一台現代機器能一邊從磁碟複製一 GB,一邊讓你不卡頓地繼續打字。
- 輪詢:CPU 一遍又一遍地問「你做好了沒?」,在一個緊湊的迴圈裡反覆讀裝置的狀態暫存器。簡單又即時,但這就像一個孩子每兩秒戳一次烤箱——處理器燒掉數千個週期,除了查看什麼也沒做。
- 中斷:CPU 發出命令後,便走開去做別的工作。當裝置終於完成,它舉起一個中斷——一記硬體的拍肩,把 CPU 猛地拽進一段處理常式。現在處理器只在有消息時才分神,就像一個會「叮」一聲的烤箱計時器,不必有人盯著。
- DMA:即使有了中斷,CPU 仍得親自把每一個位元組在裝置與記憶體之間鏟來鏟去。一個 DMA 引擎,是一個微小的專責搬運工,它自己完成整批傳輸,只在最後關頭中斷 CPU 一次。處理器把整趟苦力都委派出去,全程都是自由的。
把那道階梯讀成一個關於消除浪費的故事。輪詢浪費了 CPU 等待時的時間;中斷修好了這點。中斷仍浪費了 CPU 搬資料時的時間;DMA 修好了這點。這個模式——讓昂貴的通用 CPU 把枯燥、重複的工作,委派給便宜的專責小幫手——正是當初給了我們快取、後來又給了我們加速器的那同一股直覺。我們會在第三篇導覽裡仔細追蹤這三種機制;現在,只要先抓住這個形狀:反覆地問、被拍肩、或徹底委派出去。
資料究竟住在哪裡:磁碟與固態硬碟
記憶體階層最慢、最大的那一層——DRAM 底下那座遙遠的倉庫——就是持久性儲存,而它有兩種非常不同的風味。經典的硬碟是機械式的:旋轉的磁性碟片,加上一支搖臂上的磁頭,擺到正確的磁軌上。讀一個位元組,意味著要等搖臂移過去(尋軌延遲),再等碟片把那個位元組轉到磁頭底下(旋轉延遲)。這些是物理性、毫秒等級的延遲——對 CPU 而言宛如永恆——這正是為什麼對磁碟做隨機存取如此殘酷,也是為什麼磁碟反而偏愛又長又連續的串流。
固態硬碟完全沒有任何會動的零件——它把位元存在快閃記憶體單元裡,所以沒有尋軌、沒有旋轉,隨機讀取快上數百倍。但快閃有它自己古怪的規則,形塑了一切:你能以小小的頁為單位讀寫,卻只能以大大的區塊為單位抹除,而一個單元必須先被抹除,才能被重寫。這種「寫前須抹」的不對稱,意味著固態硬碟無法單純地就地覆寫資料。一層叫做快閃轉譯層的韌體把這藏了起來,悄悄地重新映射你的資料實際住在哪裡,並把區塊搬來搬去,好讓磨損平均地分散開來。這顆碟看起來像一個單純的磁區陣列,但它內部有一台小電腦正費力地維持著這個錯覺。
兩個故事共享一個值得帶著爬完剩下階梯的教訓:儲存的效能由你怎麼存取它主宰,而不只是裝置的原始規格。一個照順序、一路讀穿整個檔案的程式,對磁碟的連續串流與固態硬碟的乾淨寫入都很友善;一個在隨機散落的位置上東戳西戳的程式,則折磨磁碟的搖臂,也逼固態硬碟去做昂貴的抹除與搬移。這正是區域性原理當初教我們關於快取的同一個寓意,如今以慢上千倍的步調、伴著真正旋轉的金屬重演——決定你命運的是存取模式,而不是百萬赫茲。
第三個尺度:活下去
延遲與吞吐量問的是有多快;第三個尺度問的是有多值得信賴。在 CPU 內部,我們大多假設硬體就是會正常運作。一到輸入輸出的世界,這個假設就崩塌了:磁碟會死、纜線會弄壞位元、電源會閃爍、一顆宇宙射線會翻轉一個記憶體單元。可靠度/可信賴性是一個總稱,指一個系統儘管面對這一切,仍持續把對的事做對——而它分成兩個值得區分清楚的概念。可靠性問的是「多久才會壞?」(常以平均故障間隔時間概括),而可用性問的是「它實際上有多少比例的時間真的醒著、在服務?」一個系統可以既不可靠卻又高度可用,只要它恢復得夠快。
對這一切的解答,正是大自然所用的那一個:冗餘——多留不只一份副本、或不只一顆碟,好讓單一一次故障不至於釀成大禍。這一級接下來的兩個概念都源自於它。RAID 把好幾顆磁碟綁成一顆邏輯碟,存進額外的同位資料,於是萬一任何一顆碟死了,遺失的資料都能從倖存者身上重建出來。錯誤更正碼則在每一塊資料上加進幾個聰明的額外位元,讓硬體不僅能偵測到一個翻轉的位元,還能在任何人察覺之前悄悄地把它修好。兩者都靠付出冗餘來買到可靠度:多的碟、多的位元、多的成本——從不免費,永遠是一筆交易。
所以這整級懸在三個尺度上,而最深的、誠實的教訓是:它們彼此互相拉扯。用巨大的批次去追純粹的吞吐量,會傷到延遲。用冗餘去買可靠度,要付出吞吐量與金錢。沒有單獨一個標著「更好」的旋鈕——設計輸入輸出,是一門選擇你的系統必須在哪個尺度上取勝、並優雅地犧牲其餘的手藝。爬接下來四篇導覽時,請把這個三角形——延遲、吞吐量、可靠度——記在腦中;我們遇見的每一種機制,其實都是在它三個角之間討價還價的不同方式。