我用python試圖檢測鍵盤事件,用的方法是在while迴圈中放置了pygame中的一個擷取事件的函數event.get(),結果就是cpu始終佔用100%。但是作業系統和其他語言(如C#)的事件監聽函數基本不佔cpu,它們是如何做到的?是犧牲了事件響應的即時性嗎?
回複內容:
作業系統的事件監聽是靠與CPU協作完成的,這一機制叫作
硬體中斷(Interrupt)。
正常情況下,CPU按照它內部程式計數器(Program counter
)所指的指令(Instruction
)順序執行,或者如果所指的指令是跳轉類指令,比如
x86類的
CALL或者
JMP,跳轉到指令所指的地方繼續順序執行。
只有四種情況,CPU不會順序執行,包括上面所說的硬體中斷,Trap(Trap (computing)
),Fault,和Abort。
硬體中斷時,CPU會轉而執行硬體中斷處理函數。Trap則是被x86的
INT指令觸發,它是核心系統調用(System call
)的實現機制,它會使得CPU跳轉到作業系統核心的系統函數(Exception Table)。Fault則是除0報錯,缺頁中斷(Page fault
),甚至記憶體保護錯誤實現所依靠的。
不會順序執行的意思是,CPU會馬上放下手頭的指令,轉而去處理這四種情況,在把這四種情況處理完之後再依靠CPU跳回到剛才的指令繼續處理。這就是為什麼即使單核CPU在100%佔用處理另一個進程任務時,只要你的進程優先順序夠高,也能在鍵盤事件發生時讓CPU停下而轉而執行你的進程。
為什麼監聽鍵盤事件可以不佔100%CPU,甚至可以0佔用CPU呢?因為即使在CPU完全停止而不執行指令的狀態(Idle (CPU)
),硬體中斷仍然會啟動CPU開始執行中斷處理函數(Interrupt handler
)。
特別的,當你按下Ctrl+C時,鍵盤硬體會給CPU一個硬體中斷,其中包含一個異常號(Exception Number),CPU拿到這個異常號馬上調用之前由作業系統核心(Kernel (operating system)
)註冊的中斷處理函數,中斷處理函數會調用核心中的鍵盤驅動,鍵盤驅動調用顯示終端(Computer terminal
)驅動在終端上顯示"^C",同時通過調度器來喚醒相關的進程(Process (computing)
)。*nix還會產生訊號(Unix signal
)發送到當前的進程組(Process group
),這些進程則會執行
SIGINT的訊號處理函數,這時我們的CPU就從核心模式(Kernel Mode
)轉到了使用者模式(User mode
)。
如果在這一事件發生之前你的進程使用了阻塞式(Blocking)的等待鍵盤響應,並且使用者並沒有什麼程式執行。那麼CPU大多數時候會被核心代碼中的
HLT指令轉到空閑狀態,只會被時鐘的硬體中斷周期性喚醒看看調度器有沒有什麼事情可以做。當鍵盤時間發生時,你的進程就會被喚醒,從等待函數中返回,繼續執行之後的代碼。你也將在電腦上螢幕上看到CPU仍然是0佔用。
參考: Computer Systems: A Programmer's Perspective (3rd Edition)
這個問題涉及到系統處理事件的兩種方法, 容我來組織一下語言。一種是interrupt,由CPU的中斷機制來提醒作業系統發生了什麼;另一種需要作業系統主動polling, 不停檢查某應用程式是否有事件發生。作業系統一般是通過CPU中斷機制來對應用程式提供服務的,原因很明顯,使用第二種方法作業系統會不停地佔有CPU資源來檢查是否有事件發生。而且恰恰第二種方法即時性較差。想象使用者程式要被timer interrupt打段之後才能進入kernel,然後kernel處理一些任務之後才會逐個poll所有進程的狀態,這中間的latency肯定要比直接觸發中斷要大很多。
CPU中斷機制有4種,trap, fault, interrupt, abort。他們之間有非常微妙的差別。這四種中斷可以分為兩大類,一類是同步發生的,包括trap, fault, abort,統稱exception。另一類是非同步發生的interrupt。trap是應用程式向系統主動申請服務的一種機制,syscall就是其中一種實現,所以我們通常說a user process traps into kernel。abort指的是程式的執行發生了一些意料之外的情況,通常是無法恢複執行的,比如硬體錯誤。fault也是指程式執行中的一些異常狀況,但是通常是可以恢複執行的,比如page fault。最後interrupt通常是由外部輸入硬體主動觸發的中斷,鍵盤、滑鼠、觸屏,timer等等就屬於這種。這部分更詳細的解釋可以參考CSAPP的第二版的8.1節。
要描述清楚為什麼系統監聽鍵盤事件幾乎不佔用任何CPU資源,必須解釋一下電腦系統如何處理external interrupt。假設我們在shell中跑了一個程式,然後按Ctrl + C來退出。首先,在我們按下Ctrl +C的時候,鍵盤控制器會發起一個interrupt並轉給CPU。CPU通過查看自己的IDT (interrupt descriptor table),來找到與keyboard interrupt相對應的interrupt handler,CPU會自動把觸發keyboard interrupt的進程(shell)的stack pointer, instruction pointer等資訊push到kernel stack上,然後轉到kernel stack和之前查到的interrupt handler開始執行。kernel會繼續將剩餘所有中斷時的寄存器值全部push到kernel stack上形成一個trapframe,以便處理完中斷後恢複執行shell。然後kernel根據CPU給的中斷資訊選擇一個封裝在kernel內部的interrupt handler繼續執行。這個interrupt handler會拿到shell的context,然後給shell發送一個INT訊號並恢複執行shell。shell接收的這個訊號後會轉到自己的INT signal handler繼續執行。然後這個handler會調用一個system call kill來關閉自己跑在foreground的子進程(使用鍵盤的進程一定跑在foreground上),然後等待使用者輸入下一個命令。至此硬體+作業系統+shell相互合作處理一個鍵盤輸入的過程就結束了。此過程省略數千字=,=。看起來很繁瑣,但是系統就是這樣,保證共用硬體資源的同時還要最大化效率,而且還要有足夠的保護機制 =,=
EDIT: 修改了第二段關於四種中斷機制概念的解釋,更精確一些。不過這些術語本來就沒有完全統一的說法。我覺得對於某種中斷情況,例如page fault, external interrupt, syscall, divide by zero, 瞭解kernel如何進行處理就可以了,不必在意用語上的細節。
因為你調用的函數不是阻塞的,又沒有加等待,所以會佔滿cpu。
如果輪詢,則在迴圈中插入等待(sleep是其中一種),換句話說讓cpu休息。
假如一次輪詢需要0.1毫秒,增加10毫秒的等待,會使CPU每工作0.1毫秒休息10毫秒,如此,cpu佔用下降到1%。
以上是一種很直觀的解釋。輪詢或中斷可以被封裝成阻塞式調用,也就是說,你無需處理如何讓cpu休息的問題,無訊息時一直阻塞在該函數裡面休息,當函數返回時必定能返回結果。如果你使用的函數本身是阻塞的,則你無需考慮cpu佔用。
阻塞式調用解決了cpu滿負荷的問題,因為內部已經包含了讓cpu休息相關的操作。
然而阻塞式調用誕生了一個新問題:如何同時查詢多個不同的訊息呢?如何同時輪詢多個不同的東西呢?
於是你無意中發現了非同步通訊的核心。簡單的說,一般就是在一個函數裡面同時收聽多個訊息源,任何一種訊息來了都反饋給你。如果該函數是阻塞的,則它必定返回一個訊息。
但有時你希望除了訊息之外還添加一點私貨,所以會讓函數不阻塞,這種情況下你仍然需要在迴圈中增加等待,避免cpu滿載。
非同步事件處理機製成為了現代伺服器編程的主流,因為只有非同步處理機制能夠在短時間內處理龐大的請求數量而且不過分佔用資源,多線程/多進程機制是無法做到的。
一言以蔽之:非阻塞式輪詢,請加等待。不想加等待,請用阻塞式調用。同時輪詢多個事件,用非同步事件處理機制。你要監視一個人的行蹤,有兩種方法:
1.不讓他知道,你一天到晚盯著他,這會佔滿你的時間,但對他卻沒有任何影響,這叫做輪詢
2.告訴他你關注著他,在他做了你關心的動作時叫他通知你,這幾乎不佔用你的時間,但對他來說會多一件不太麻煩的事,這叫做中斷我上一家就職的公司業務很忙,我的組長不但要處理本組的事物,還要對外響應其它組的需求。這是背景。
最開始大家有事都發郵件溝通。組長的工作方式是:處理一輪本組事物,查郵件看看來自外部的需求,有郵件就處理完再處理本組的事,沒有就直接處理本組的事。這叫輪詢。
突然有一天大領導有急事找他,給他發了封郵件。等了半個小時才得到回應,耽誤了事。大領導很生氣訓我組長一頓。他總結教訓,發現有些事是要及時響應的,於是給可能有急事的人說以後有事打電話。接到電話後他會放下手中的事立即處理電話來的需求,處理完了接著之前的工作。這叫中斷。
後來有一天,HR給他打了個電話,要他去HR那領份材料處理完成再交給HR。他屁顛屁顛跑去領材料處理完,再交給HR的時候被告知其實三天內搞定都行。他仔細想了想,有些事開始的部分很急,比如領表,後面沒那麼急而且蠻費事。在接到某些電話時,他立即把前半部分處理完,然後扔下,擇期處理。這叫硬中斷和非強制中斷分離。
再後來…公司的快遞太多,大領導讓他代領所有人的快遞。於是他一天內接到了無數個快遞小哥打的電話。累得要死。這叫中斷風暴。
於是第二天他給門房大爺打了個招呼,讓大爺暫存快遞,第一個快遞來的時候給他打個電話,他在中斷上半部把這個事兒記下來。要是一天都沒快遞就不要打擾他。他在快下班時從門房把一天的快遞領走發給大家。這叫在非強制中斷輪詢處理。
所以題主,如上面寫的,在忙的時候中斷是要比輪詢更及時得到響應的。而你做的是讓我如此聰明的組長閑的時候,對著郵箱不停按重新整理……是的,犧牲了事件處理的即時性。另兩個答案說的對,事件檢測就是靠輪詢或者中斷。我來解釋下為什麼你的代碼會佔用100%CPU:
以Win工作管理員中的CPU使用率顯示為例,我們知道,Windows是靠給每個線程分配時間片輪流執行來實現多線程/進程的,每個時間片大約是幾毫秒到十幾毫秒。Win8工作管理員預設以1秒1格的速度繪製CPU使用率曲線,也就是統計過去這1000ms裡,有多少百分比時間被除了system idle進程使用了。你的Python使用了while死迴圈,會造成線程一直在使用分配到的時間片,所以CPU使用率會是100%。實際上你只要在while裡加個time.sleep(0.001)就能使CPU降下來了。
sleep函數,是告訴作業系統,本線程要放棄當前未用完的時間片,並在接下來的指定時間內不要給我分配時間片。實際上整個作業系統裡的進程在絕大多數時間裡,都在sleep等待著,隔一會檢查一下使用者輸入、訊息投遞之類的事件發生,所以CPU使用率並不高。sleep越多,單位時間內CPU使用率就越低。監聽一個事件是否發生有兩種方法,一種是輪詢,另一種是中斷,第二種在Windows環境裡,也可以稱為鉤子(不要太糾結叫什麼,理解它就可以了)。
輪詢就是你用python寫的方法,
一個while不停的檢查,不停的詢問:發生了沒、發生了沒……
鉤子是什麼意思呢?在Windows環境裡,任何事件都以事件廣播的形式發送到所有的視窗,視窗收到了事件然後去處理,對於一個按鍵(鍵盤)事件,大概的流程是這樣的(XP-WIN7時代流程):
1)硬體中斷/硬體連接埠資料
//WinIO能類比,或者修改IDT是在這一層
2)鍵盤Port驅動(USB or PS/2)
//Filter驅動在此
//KeyboardClassServiceCallback也在這一層被調用
3)kbdclass驅動
//處理鍵盤配置和鍵盤語言,部分高端的病毒也工作在這裡
4)Windows核心邊界(zwCreate/zwReadFile)
----------------------(系統調用)----------------------
5)Windows核心邊界(zwCreate/zwReadFile)
6)csrss.exe的win32k!RawInputThread讀取,完成scancode和vk的轉換
//SetWindowHook工作在這裡(全域)
//kbd_event工作在這裡
7)csrss.exe調用DispatchMessage等函數分發訊息(
此處開始廣播鍵盤訊息)
//SetWindowHook工作在這裡(進程)
//PostMessage和SendMessage在這裡
8)各個進程(視窗線程)處理訊息
在第6步那,如果掛上一個鉤子,那麼理論上所有常規的按鍵訊息就都能收到了,當沒有按鍵訊息的是時候,這個鉤子函數是不會被調用和執行的,所以必然也不會佔用CPU。
同樣的道理也適用於其它鉤子:
事件未發生,鉤子未被執行,所以不佔CPU。Don't call me, I will call you.........補充一下,事實上中斷是必要條件,但是不充分。試想如果除了時鐘沒有任何中斷源發生事件,CPU還是在啟動並執行。區別是這個時候可以把它設定為低功耗狀態。想起來以前單片機裡面的計時器延時和硬代碼延時