虚拟化之进程

在我们日常使用电脑的过程中,肯定不是只有一个程序在运行,比如一边打游戏,还可以一边听歌,然后再挂着一个浏览器、qq、微信等等,看起来它们都是同时在运行的一样。这每一个在操作系统中运行着的程序都可以理解成一个进程,然后通过分时共享CPU技术,让每个进程都只运行一个时间片,然后再切换到别的进程再运行一会,从而让我们感觉所有的程序都是在同时运行的一样。当然,这种技术的必要的开销就是每个进程都会比理想中的运行稍慢一些。

进程的概念#

程序肯定不会凭空运行,往往是我们点了对应的执行文件,或是输入了对应的命令后,程序才开始运行。那么我们自然会好奇,只是在终端中打了几个字,程序是如何以一个进程的形式运行的?
在没有点击或是输入命令前,毫无疑问程序就是一个存储在磁盘上的一个文件,具体说是个可执行文件。在输入命令之后,操作系统会将这个可执行文件从磁盘加载到内存中,包括代码和静态数据等,这些内容都被读取到了内存中的某段位置。只有代码和静态数据还是不够的,系统还给我们分配一些空间用来存放局部变量啦、函数参数和返回地址啦等等,这部分空间称为栈,然后还有一个称为堆的部分,需要程序自己来申请和释放对应的内存空间。大体上就是这些,现在程序可以愉快地运行了。通过这样描述,似乎进程也可以理解为内存上的一段加载了执行文件内容的有意义的空间。

进程有三个状态,运行、就绪和阻塞。
运行就意味着当前进行在执行对应的指令;就绪是指进程已经准备好了可以运行,但是因为某些原因操作系统选择此时选择不执行;阻塞就是进程在做了某个操作后不能继续往下执行了,需要等待某个事件发生后才会准备运行,比如当进程向磁盘发起I/O请求时,只有在收到磁盘的回复后才会继续执行后面的指令,而如果磁盘迟迟没有回复,那它就会被阻塞,此时其他进程就可以先使用CPU。
那么,进程还可以理解为是一个状态机:进程执行时是在运行状态,当运行到向磁盘发送I/O请求的指令时,进程进入了阻塞状态等待I/O完成,完成后进程又进入了就绪状态,这时就等待操作系统来调度,当操作系统决定是这个进程运行时,这个进程又回到了运行状态。另外,如果进程的时间片用完了,或者因为其他原因被操作系统取消了运行的权力,它会回到就绪状态,等待操作系统允许运行。于是,进程在退出前都会在这三种状态间来回切换。

实际上,进程也就对应了一些数据,一个进程停止时,当前时刻的寄存器信息会被保存到内存上一个专门保存进程信息的数据结构中,然后在下次运行时读取并恢复对应的寄存器的信息,该进程又可以继续往下运行了,从进程的角度来看,好像发生了时停一样,它是感觉不到的。这种技术被称为上下文切换。

进程的接口#

linux系统中通过fork()exec()这对系统调用接口来实现进程的创建,还可以通过wait()来等待其创建的子进程执行完成。

int main(int argc, char** argv) {
    cout << "hello process[" << getpid() << "]\n";
    int rc = fork();
    if(rc < 0) {
        cerr << "fork failed\n";
        exit(1);
    }
    else if(rc == 0) {
        // child process
        cout << "this is child process[" << getpid() << "]\n";
    }
    else {
        cout << "this is parent process[" << getpid() << "] of child " << rc << '\n';
    }
    return 0;
}

运行上述内容,终端将会打印如下内容:

hello process[2491]
this is parent process[2491] of child 2492
this is child process[2492]

我们多运行几次会发现,有时候子进程还可能先打印出来,也就是说并不是先执行父进程再执行子进程的,先执行哪个是随机的,不过在我测试的时候因为机器比较闲,绝大多数都是父进程先打印了,毕竟子进程还需要个创建进程环境的过程。
如果我们需要父进程等到子进程执行完后再执行,可以调用系统接口waitpid()或者wait(),如下:

int main(int argc, char** argv) {
    cout << "hello process[" << getpid() << "]\n";
    int rc = fork();
    if(rc < 0) {
        cerr << "fork failed\n";
        exit(1);
    }
    else if(rc == 0) {
        // child process
        cout << "this is child process[" << getpid() << "]\n";
    }
    else {
        while(true) {
            int stat;
            pid_t p = waitpid(rc, &stat, WNOHANG);
            if(p != 0) {
                cout << p << endl;
                break;
            }
        }
        cout << "this is parent process[" << getpid() << "] of child " << rc << '\n';
    }
    return 0;
}

得到结果

hello process[2788]
this is child process[2789]
2789
this is parent process[2788] of child 2789

如果将代码中的waitpid()写成waitpid(-1, &stat, 0)就会退化成wait(&stat),表示等待任意的一个线程退出,如果没有返回0。
最后是exec()族函数,除了最原始的exec()外,还有execl()execle()execlp()execv()execvp()变体,入参不同,不过都是在当前进程中执行一个新的程序。例如我们再写一个简单的可执行文件,并且修改下原来的创建进程程序

// new_file.cpp
int main(int argc, char** argv) {
    cout << "this is a new file\n";
    cout << "argv: ";
    for(int i = 0; i < argc; i++) {
        cout << argv[i] << " ";
    }
    cout << endl;
    
    return 0;
}

// process.cpp
int main(int argc, char** argv) {
    cout << "hello process[" << getpid() << "]\n";
    int rc = fork();
    if(rc < 0) {
        cerr << "fork failed\n";
        exit(1);
    }
    else if(rc == 0) {
        // child process
        char* argv[3];
        argv[0] = const_cast<char*>("./new_file.out");
        argv[1] = const_cast<char*>("param");
        argv[2] = NULL;
        execvp(argv[0], argv);
        cout << "this is child process[" << getpid() << "]\n";
    }
    else {
        int stat;
        pid_t p = waitpid(rc, &stat, WUNTRACED);
        cout << p << endl;
        cout << "this is parent process[" << getpid() << "] of child " << rc << '\n';
    }
    return 0;
}

执行结果如下

hello process[4156]
this is a new file
argv: ./new_file.out param 
4157
this is parent process[4156] of child 4157

如果细心对比下程序和输出会发现,子程序没有打印execvp()下面的一行,这也这是exec()这些接口的特点,如果调用成功会将当前进程全部清空,然后执行exec()入参中指定的可执行文件,因此exec()后面写的这些内容只有没有调用成功时才会执行。另外因为清空了进程,所以永远也得不到exec()成功执行的返回值,很好玩。

除了上述的三个接口,还有许多与进程交互的方式,比如kill()可以给进程发送个信号等。

机制:受限直接执行#

时分共享CPU的基本思想是运行一个进程一段时间,然后运行另一个进程,如此轮换。实现这种机制存在一些挑战,一是性能,如何在不增加系统开销的情况下实现;二是控制权,如何有效地运行进程,同时保留操作系统对CPU的控制权。
为了使程序尽快得运行,提出了一种称为受限的直接执行技术:当OS希望启动程序运行时,它会在进程列表中为其创建一个进程条目,为其分配一些内存,将程序加载到内存中,找到入口点(main函数或其他类似的),跳转到那里,并开始运行用户的代码。

问题1:控制权#

直接执行无疑是快速的,但是还需要解决进程的控制问题,如果进程代码要执行一段受限的操作,例如读写磁盘或获得更多系统资源,要怎么办?
为了解决这个问题,操作系统引入用户模式的概念,在用户模式下的代码会受到限制,只能操作对应分配的资源,不能执行类似直接向磁盘发送I/O命令等需要直接与底层交互的操作。
与之对应的是内核模式,内核模式可以做任何操作,包括磁盘读写、分配内存等等。
但是程序中往往也需要执行某种特权模式,比如读取一个文件的内容或是申请更多的内存资源等等,这就需要一个桥梁来连接内核模式和用户模式,即所谓的系统调用,操作系统通过接口的形式暴露了数百个系统调用函数(详见POSIX标准),进程在用户模式下如果确实需要执行特权操作,就像调用普通函数一样调用系统提供的接口函数,使进程进入内核模式去完成对应的操作命令然后再退出到用户模式,通过封装一层,保证了操作系统始终拥有着资源的控制权,而进程在用户模式最多只能执行系统提供的操作,避免了恶意进程搞东搞西把系统破坏掉。
在系统调用函数中,会执行一个特殊的陷阱(trap)指令,这个指令会跳入内核并将进程的权限级别提升到内核模式。在完成工作后,再调用一个特殊的从陷阱返回(return-from-trap)指令,使进程重新返回到发起调用的用户程序中,同时将权限级别降低到用户模式。
有一个细节,trap是如何知道OS内运行哪些代码的,显然不可以让调用方指定地址的,不然会让程序跳转到内核的任意位置,这是很危险的行为。
实际上,内核会在启动时设置一张陷阱表(trap table),存放了系统调用接口到对应位置的映射。当机器启动时,它在内核模式下执行,因此可以根据需要自由地配置机器硬件,操作系统做的第一件事,就是告诉硬件在发生某些异常事件时要运行哪些代码。

受限直接运行协议
操作系统@启动(内核模式) 硬件
初始化陷阱表
记住系统调用处理程序的地址
操作系统@运行(内核模式) 硬件 程序(用户模式)
在进程列表上创建条目
为程序分配内存
将程序加载到内存中
根据argv设置程序栈
用寄存器/程序计数器填充内核栈
从陷阱返回
从内核栈恢复寄存器
转向用户模式
跳到main
运行main
...
调用系统调用
陷入操作系统
将寄存器保存到内存栈
转向内核模式
跳到陷阱处理程序
处理陷阱
做系统调用的工作
从陷阱返回
从内核栈恢复寄存器
转向用户模式
跳到陷阱之后的程序计数器
...
从main返回
通过exit()陷入操作系统
释放进程的内存
将进程从进程列表中清除

一个进程从生到死的过程基本那就是上表从上到下的顺序。

问题2:进程间的切换#

进程间切换似乎只要停止一个进程,然后再运行另一个进程就可以了,但实际上存在的问题是一个进程在运行时意味着操作系统此时并没有运行,如果操作系统都没有运行那它什么也做不了。
过去的一些操作系统可能会采取一种协作的方式,选择相信运行的进程会调用系统调用来自觉地让出控制权,然后操作系统就可以再运行其他进程。或者说进程执行了非法的操作,被强行终止,被迫将控制权交回给操作系统。但是如果进程陷入了死循环,或是并不主动让出控制权又怎么办呢?
这时OS会采用另外一种非协作的方式——时钟中断,时钟设备每隔几毫秒就会产生一次中断,产生中断时,会打断当前正在运行的程序,运行一个操作系统中预先配置好的中断处理程序,这时操作系统又获得了控制权。需要注意的是,硬件在发生中断时必须要担负起一定的责任,尤其是中断发生时,它必须保存好正在运行的程序足够的状态,以便在返回时仍能正确地继续运行程序。
如果决定要进行进程切换,那么OS就会执行一些底层的代码,即所谓的上下文切换——为当前正在执行的进程保存一些寄存器的值到一个特定的地方(比如它的内核栈中),并为即将执行的进程恢复一些寄存器的值(从之前保存的地方)。

受限直接执行协议(时钟中断)
操作系统@启动(内核模式) 硬件
初始化陷阱表
记住以下地址:系统调用处理程序、时钟处理程序
启动中断时钟
启动时钟,每隔一定时间中断CPU
操作系统@运行(内核模式) 硬件 程序(用户模式)
进程A ...
时钟中断
将寄存器(A)保存到内核栈(A)
转向内核模式
跳到陷阱处理程序
处理陷阱
调用switch()例程:将寄存器(A)保存到进程结构(A),将进程结构(B)恢复到寄存器(B)
从陷阱返回(进入B)
从内核栈(B)恢复寄存器(B)
转向用户模式
跳到B的程序计数器
进程B ...

进程调度#

现在我们知道了操作系统是如何执行进程和切换进程的,下一步要思考的到了该怎样切换进程,只是简单的按照顺序来切换显然是太过于幼稚了,不同的进程总会有轻重缓急之分,所以需要一些策略来决定某一时刻该切换到哪个进程上。

我们不妨先对要运行的进程做出一些合理的假设:

  1. 每个进程都运行相同的时间。
  2. 每个进程都同时到达。
  3. 一旦开始,每个进程都保持运行直到结束。
  4. 所有的进程都只是使用CPU,而没有I/O操作。
  5. 每个进程的运行时间是已知的。

诚然,这些假设是不太现实的,并且在系统真实的系统进程调度中往往也不满足这些假设,但这有助于我们分析。
此外我们还定义了一个概念——调度指标,用来衡量进程的重要性等意义。例如,周转时间 = 完成时间 - 到达系统的时间,或者说公平性,表明每个进程被调度的概率都是相等的。这里需要提一下,性能和公平在系统中往往是矛盾的,比如为了优化性能,我们可能会阻止某些进程被调度,这就降低了公平性。

先进先出(FIFO)#

这是最基本最简单的调度策略,也很容易理解。但是假设一种不满足假设1的场景,有A、B、C三个进程,将依次被CPU运行,如果A需要10s执行完成、B需要1s执行完成,C需要4s执行完成。那么B将在11s后才能得到结果,而C将在15s后才能得到结果,整个系统的平均周转时间是(10+11+15)/3=7s。

最短任务优先(SJF)#

如果采用最短任务优先的策略,B先执行,C再执行,最后再执行A,系统的平均周转时间就等于(1+4+10)/3=5s,相比先进先出策略性能提升了接近30%。
如果A、B、C同时到达,最短任务优先无疑是最优的调度策略,但如果我们放开第2条假设,在A运行期间,B和C才到达,那么结果就和FIFO是一样的。

最短完成时间优先(STCF)#

为了保证系统的高效性,如果我们放开了假设2,就不得不再放开假设3,不再保证进程一旦运行就必须完成。于是利用时钟中断和上下文切换的方式,当B和C在A运行期间到达时,发现B和C的结束时间要比A的早,那么立刻切换到B和C进程上运行,当B和C结束后再切回到A上完成剩下的工作。

响应时间和轮转#

上述的调度策略已经是非常好的了,但是似乎在现代的操作系统上的观感并不太好,你不会希望开一局游戏必须要在电脑的浏览器处理完信息、音乐播放器播放完音乐、QQ接收完好友发来的消息等等结束后才能开始加载吧?分时系统要求系统必须要有良好的交互性,于是诞生了一个新的度量标注——响应时间,响应时间 = 首次运行时间 - 到达时间。
接下来我们就要考虑如何构建一个对响应时间敏感的调度策略。
于是我们引入了一种新的调度算法——轮转。基本思想很简单:在一个时间片内运行一个进程,然后切换到运行队列中的下一个进程,如此反复,直到所有任务都完成。这样就等于把大的任务进程切分成了若干小的任务,再通过其他的调度策略,实现了多个进程可以看似同时在工作。另外,时间片的设定必须是时钟中断中期的倍数,比如时钟每隔10ms中断一次,那么时间片就可以设置为10ms、20ms或40ms等等。
毫无以为,通过轮转的方式在周转时间指标下的性能是变差了的,甚至可能是最差的,但这是对多个进程都可以拥有良好交互性的妥协。
再回到我们的假设,还有假设4和假设5,实际上,这两个假设才是最扯淡的。任何一个稍微复杂的程序都可能难以避免地会使用到I/O操作,比如读取文件或是网络通讯等等,而I/O操作往往是很耗时而又无需CPU参与的,如果有A和B两个进程,A需要运行10ms,然后执行若干10ms的I/O操作(每个I/O返回时还需要A处理下),而B需要连续运行50ms,难道要A先执行,然后在I/O期间也要B等着,这显然不合理嘛。
现在常见的办法是将A的每10ms看作一项独立的任务,当A运行完成开始进行I/O操作时,B就可以抢占CPU,然后当A的I/O返回时,再注册一个A的新的任务,抢占CPU,如此循环,从而使系统得到了良好的利用。
最后的假设5是最糟糕的假设,因为实际上操作系统对进程的运行时间不能说是一无所知,也可以说是知之甚少。

多级反馈队列(MLFQ)#

多级反馈队列需要解决两方面的问题。

  1. 优化周转时间,正如假设5是不成立的一样,操作系统不知道进程要工作多长时间,而这又是调度策略所必需的。
  2. 为了更好的交互体验,需要降低响应时间。

多级反馈队列是利用历史经验预测未来的典型的例子。如果工作有明显的阶段性行为,因此可以预测,那么这种方式会相当有效。但需要小心使用,这也可能让你做出一文不值的决策。

正如其名字的字面意思,MLFQ中有许多独立的队列,而每个队列都会有不同的优先级,任何时刻,一个任务只会存在一个队列中,且MLFQ总是执行优先级较高的任务,对于相同优先级的任务间采用轮转的方式调度。
所以MLFQ的关键点就在于如何设置优先级。根据观察到的行为动态地调整任务地优先级:如果一个任务不断地放弃CPU去等待I/O,这是交互型进程的可能行为,那么就让它保持高优先级,而那些长时间占用CPU的任务,则降低其优先级。通过这种方式,MLFQ不断利用历史记录来预测其未来的行为。
当然,以上的简单行为还会存在一些问题,比如系统中有大量的交互性任务,导致CPU密集型的任务没有机会被执行;或者是一些狡猾的进程在自己的时间片中都会进行一次无关的读取操作,从而欺骗系统使之始终保持在高优先级。因此我们需要一些优化的规则:

  • 规则1:如果A的优先级 > B的优先级,运行A。
  • 规则2:如果A的优先级 = B的优先级,采用轮转的方式。
  • 规则3:任务进入系统时,放在最高优先级。
  • 规则4:一旦任务用完了其在某一层中的时间配额,就降低其优先级。
  • 规则5:经过一段时间后,就将系统中的所有任务重新放入最高优先级队列。

最后,MLFQ在使用中还是有些问题需要因地制宜的处理。比如规则5中经过一段时间是多久?太短了交互型任务无法得到充分的工作,而太长了CPU密集型的任务又会得不到运行的机会。
还有要配置多少层的队列,每一层队列要分配多长的时间片?这些都没有明确的答案,需要根据经验来做出抉择。

比例份额#

比例份额是一个不同类型的调度策略,其基于一个简单的想法:调度程序的最终目标,是确保每个工作获得一定比例的CPU时间,而不是优化周转时间和响应时间。
现代的优秀例子——彩票调度(lottery scheduling):每隔一段时间举行一次彩票抽奖,来确定接下来运行哪个进程,越是频繁运行的进程,越是拥有更多赢得彩票的机会。


多处理器调度#

以上的讨论都是局限在单核CPU上,现代CPU都是有多个核心的,因此如何在多个处理器上实现进程的调度是更为重要的,当然也更为困难。
CPU的每个核心都有一个很小但是很快的缓存,CPU从内存中读取数据相对是满的,但是从缓存中读取数据则是较快的,于是缓存基于时间局部性和空间局部性,将数据从内存先读到缓存中,CPU再从缓存中读取,这在理想中是非常高效的。
但是CPU往往共享一个内存,而缓存则是每个核心自己独有的,那么就会导致CPU有可能读到不同的值,这正是缓存一致性问题。
为了尽可能地提高CPU的效率,调度时需要考虑尽量保证数据在缓存中,并尽可能将进程保持在同一个CPU中,这可以避免重新加载数据而导致执行变慢(当然,重新加载有时是很难避免的)。

单队列调度(SQMS)#

简单地复用单处理器调度地基本架构,将所有需要调度的工作放入一个单独的队列中,称为单队列多处理器调度。
任务都将分配在各个CPU中运行,这样做的优点是足够简单,但存在较明显的短板。

  1. 缺乏可扩展性,为了能在多个CPU上正常执行,需要在代码中加锁来保证原子性。
  2. 缓存亲和性不好,每个任务都可能会放到不同的CPU上执行,这就需要数据的重新读写。

多队列调度(MQMS)#

多队列调度是针对单队列调度的不足而提出的,多队列调度的方案就是按照CPU的数量来为每个CPU都分配自己的队列,当任务到达时,通过一些规则,比如随机数或选择较空的队列等等,选择一个CPU队列。
这样做具有了很高的可扩展性,多个CPU就可以有多个队列;并且每个任务都会在一个进程中执行,就会具有较好的内存亲和性。
不过,这样做也会有自己的问题,最典型的就是“一核有难,八核围观”的负载分布不均的问题。通过允许在不同的CPU间移动任务可以一定程度的缓解这种窘境,不过在实践中将又是一个基于经验的复杂工作。
似乎也正是如此,不同的Linux系统会采用不同的调度方式,至今也没有一个统一的最好的方案。

作者:cwtxx

出处:https://www.cnblogs.com/cwtxx/p/18718227

版权:本作品采用「署名-非商业性使用-相同方式共享 4.0 国际」许可协议进行许可。

posted @   cwtxx  阅读(13)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 分享一个免费、快速、无限量使用的满血 DeepSeek R1 模型,支持深度思考和联网搜索!
· 使用C#创建一个MCP客户端
· ollama系列1:轻松3步本地部署deepseek,普通电脑可用
· 基于 Docker 搭建 FRP 内网穿透开源项目(很简单哒)
· 按钮权限的设计及实现
more_horiz
keyboard_arrow_up dark_mode palette
选择主题
menu
点击右上角即可分享
微信分享提示