我用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还是在运行的。区别是这个时候可以把它设置为低功耗状态。
想起来以前单片机里面的计时器延时和硬代码延时