OS编程接口之进程调度

在研究select运行机制的时候,看到很多资料这样描述:“select会阻塞调用进程,内核会采用轮询方式查看各端口是否有事件发生,当所监听的端口有事件发生时才会唤醒进程……”。

这样的描述会让人产生很多疑问:

1、如果调用进程被阻塞了,那么是由哪个“进程”负责执行内核代码进行轮询?

2、既然阻塞了进程,为什么还要采用轮询机制?这样就起不到节省CPU资源的作用啊?

要理解这些问题,就要更深刻地理解“进程”的本质,以及“进程调度”的本质。

之前在《OS编程接口之进程》一文中讲过“进程”的概念。从用户角度,“进程”可以理解为一组既定的“指令流”用来完成一组特定的任务。

操作系统之所以会设立“进程”这样的概念,目的就是为了实现多任务。进程从数据结构上,就是描述一组指令流的“上下文”。有个这个上下文结构,操作系统就更容易管理各个指令流,并且让CPU在指令流之间进行切换,从而实现整体效果上的“并发”执行。

一个非常直观的想法,是采用“分时”的方式,让CPU在各指令流之间切换,达到并发的目的。

之前讲过,“中断”是除了让程序主动调用jmp指令之外能够改变程序流程的唯一方式,它是CPU对外提供的一个重要接口。

在“分时”调度模式中,操作系统采用了一个“时钟中断”的机制。它利用CPU的可编程计时器,每隔一段时间(十几~几十ms级别)就让CPU产生一次定时中断。CPU一旦接收到中断,会去特定地址寻找中断处理程序,而操作系统就在这段中断处理程序中实现一段“进程调度”逻辑。比如:检查当前进程时间片是否用完,保存当前进程的上下文,寻找下一个可调度进程,切换可调度进程的上下文,恢复执行调度后的进程。(OS用什么策略来寻找和确定可调度进程,涉及到进程调度的策略和算法,不在本文讨论范文内)

这样一来,从实现效果上,我们就能看到CPU定时在各个进程之间进行切换,每个进程都有机会分得一个“时间片”,并在时间片内获得CPU资源来执行指令流中的指令。

这里要注意一件事情,当一个进程在执行的时候,CPU收到一个时间中断转而进入中断处理程序,请注意,这时候CPU事实上并没有在执行“进程”里面的指令流。因此,“一段代码一定要由某个进程来执行”这种说法是不准确的,代码不是由进程来执行的,而是由CPU来执行的。在CPU眼中,只有指令流,而没有所谓“进程”的概念。在操作系统中,“进程”是CPU的调度单位,这种说法没问题,但这绝不意味着所有代码都需要调度一个“进程”来执行。

除了定时调度之外,在现实系统中,有时候进程也有“主动”放弃CPU的情况。比如:进程执行完最后一条指令,或者,调用了sleep进入休眠状态。

如果一个进程中的所有指令都执行完了,即便它的时间片还没有用完,也没有继续等待的必要了(要知道CPU资源是弥足珍贵的);或者,如果一个进程进入了sleep状态,即使时间片还没有用完,但暂时也没啥事可干,这时候也应该触发进程切换。

此外还有一种情况,如果进程在执行过程中访问了外部设备,如:磁盘读写、网络访问,等。这时候,驱动程序指令会触发外部设备的执行电路去执行设备操作,CPU本身不在需要做什么事情,设备操作完成以后,会通过触发中断把执行权交回到CPU手中。因此,在触发设备执行以后,CPU实际上也处于“闲置”状态,此时也应该触发进程切换。

总结一下,需要发生进程切换的时机大致分为:

1、时间片切换

2、进程主动放弃CPU使用权

3、进程访问外部设备,触发驱动程序

现实中,其实还有一种情况会促发进程切换,但要解释这种情况,还需要多交待一些背景知识。

之前讲过,整个计算机技术体系就是关于“接口”的技术。CPU对外提供硬件接口,OS对上层应用提供接口。OS对上层应用所提供的接口称之为“系统调用”。

系统调用一方面是为了屏蔽底层硬件细节,为上层应用提供统一的接口。另一方面,也是为了“规范”上层应用对系统资源的使用方式。比如说,应用程序不应该随意访问整个内存区域,例如像系统中断程序所在的区域,是不允许应用程序自由访问的。内存、CPU、外部设备,一切系统资源都应该按照一套既定“规则”被上层应用所使用。系统调用可以看做是一套OS提供的既定代码,要进行相关操作,就必须通过这些既定代码来进行,而不能让用户自己随意编程实现。

出于安全和规范的目的,CPU为OS提供了一种安全机制,它把指令集和划分为不同的安全等级,同时提供了一个访问“级别”接口。在不同的安全级别下,CPU只能执行特定的指令集合。例如,安全级别0~3,3级只能执行有限的指令,0级可以无限制地使用全部指令集合。

借助这个机制,OS也对系统代码和用户代码做了安全级别上的划分,规定只有系统代码可以在级别0下运行,也就是说只有系统代码才能执行全部的CPU指令;用户代码只能在级别3下运行,也就是只能使用有限的指令集合。

如果用户代码想要进行一些级别更高的操作,就只能通过调用系统代码来实现,这就是前面提到的系统调用。

以某个用户进程为例,初始时候只能在级别3下使用CPU执行用户代码(此时称之为用户态),当进程需要访问外部设备执行write操作的时候,它需要借助OS提供的 write 接口进行系统调用。此时,指令流进入到write系统调用,它是OS自己实现的代码,所以是安全的。系统调用会先设置CPU的访问级别从3调整到0,以便能进行更高级别的操作(此时称之为内核态),之后执行相关的内核代码,执行完后会再将CPU访问级别从0调整到3,然后返回给用户代码。

可以看到,同一个进程,会根据所执行的操作不断往返于“用户态”与“内核态”之间,它平时只在用户态下执行用户代码,当需要访问内核代码时,就通过系统调用进入内核态执行相关操作。换句话说,系统调用,是进程进入内核态执行内核代码的唯一入口方式。

那这跟前面所讲的进程调度有什么关系呢?

很显然,像“进程调度”这么高级的操作,肯定是需要内核代码来进行的。还有前面说的,“时间中断程序”、“sleep操作”、“访问外部设备操作”,这些全部都是需要内核代码来执行的。

既然,进程调度需要在内核状态下进行,而进程执行系统调用也会进入内核态,那么为了降低进程在用户态、内核态之间往返切换的成本,OS会考虑在进程进入内核态以后,尽可能多的完成一些它只能在内核态下完成的事情,比如说“进程调度”。

这就补充上了前面进程切换时机的第4中情况,当进程从内核态将要返回用户态时,会根据实际情况来判断是否需要进行进程调度。

有了这些背景知识,现在再来看看select的运行机制。

首先,select是一个系统调用,它会让调用它的进程“陷入”(很多资料喜欢用这个术语)到内核状态。

select大致会做以下几个动作:

1、轮询检查所监听的端口,看是否有事件发生,若有则返回;若没有,将当前进程挂在每个监听端口的等待队列上;然后进入阻塞,让出CPU;

2、当端口上有事件发生时,会唤醒等待队列上的进程,此时也会唤醒调用select的阻塞进程;

3、进程被唤醒后,重复步骤1检查是否有事件发生;

好,知道了这些,才回头来看开篇提出的两个问题。

1、进程是进入到内核态下进行轮询的,此时可以认为select仍然是原进程指令流的一部分,因此轮询也是由原进程进行的,只不过是在内核态下而已;

2、select在轮询端口后,才将进程进入阻塞状态,此后进程的唤醒是被监听端口通过中断来进行唤醒的;被唤醒后,进程仍然需要通过执行轮询来检查到底是哪个端口发生了事件,这也是select/poll模式被质疑效率低下的原因,而epoll模式正是针对这一点做了优化提高了效率。

参考文献:

1、https://blog.csdn.net/songjinshi/article/details/23262923

2、https://blog.csdn.net/zhougb3/article/details/79792089

3、https://blog.csdn.net/sailor_8318/article/details/2870184