购买
下载掌阅APP,畅读海量书库
立即打开
畅读海量书库
扫码下载掌阅APP

5.1 进程原理

5.1.1 服务与进程

进程是满足用户需求的一系列正在执行的任务,有的为了提供一个交互的界面;有的为了提供一个后台的演算;有的为了提供一个网络服务;有的为了利用磁盘资源做存储等。归根结底,进程就是需求的承载体。PC本身就是通用化的设备,自然所有的PC系统都要满足各种各样的需求。因此,提供一个直观的进程模型就是各种服务实现的基础。

进程在种类上对用户来说千奇百怪,有的服务于医疗;有的服务于电影娱乐;有的服务于游戏。这些进程在用户看来是按行业区分的,但是在操作系统看来,所有这些进程都是对操作系统所管理的硬件资源的请求。有的需要显示器;有的需要磁盘;有的需要CPU;有的需要网络。所以,在用户看来进程是一个个不同应用种类的服务,但是在操作系统看来,是侧重于不同种类资源请求的进程。

5.1.2 资源与进程

进程对资源的需求大体分为磁盘IO密集型、网络IO密集型、内存密集型、CPU密集型和显卡密集型。这5种类型也是PC所能提供的5个主要的资源。PC资源和世界资源一样,永远是有限的,关键是找到合理分配资源的方式。世界由不同的国家构成,世界资源的分配是一个博弈的过程。而PC可以只有一个主控系统,所以可以更加合理地充分地利用资源。但是目前实现的调度算法远远称不上优秀,在使用系统时几乎不能被感知到,除非你使用的进程耗尽了几乎100%的CPU时,才会把精力放在对调度系统的优化上。

PC有个特点,就是无论是哪种服务都需要有CPU的参与,而对其他资源的依赖很多都是非独占的,例如几个进程一起使用内存、网络和显卡等。除非你有多个CPU,否则你永远无法让一个CPU同时为多个进程服务。即使你有多个CPU,其数量也不会比进程多。所以CPU的时分复用就很重要,其他资源由于其并行性,其资源管理器提供的都是申请服务模型的服务,而只有CPU的资源管理器主动发起调度。所以,必须要理解我们常说的进程调度都是指调度CPU,因为只有CPU才最需要调度。但是随着各个服务的复杂化,对于IO或者显卡的请求也越来越呈现无法充分并行的情况,也就是资源不足,而CPU也越来越具有并行能力,即CPU资源逐渐充足。所以未来的调度进程也不一定只会以CPU的时间片作为调度单元。真理是:永远以瓶颈资源作为调度的核心。可能有那么一天,CPU不是问题(现在有24核的服务器,一般不会在使用CPU时遇到瓶颈),最大的瓶颈在网络,在于各个进程都同时对有限的网络带宽提出需求。这时CPU可能就会被作为服务的提供方,网络带宽的调度程序就变成了进程调度的核心话题了。请求满足模型与调度系统是同一个功能的两种不同视角的实现。

5.1.3 进程概念

进程这个概念最早是不存在的,用现在的话来说进程就是单任务的操作系统。但在以前人们认为一个在板子上跑起来的软件只有一条执行流水线是很正常的事情,但是随着业务逻辑越来越复杂,人们对一个板子同时做多个事情的要求也越来越大,于是人们想办法同时模拟出多个正在执行的代码段。之所以说模拟,是因为以前CPU着实只有单核,其同时执行多个代码就超出了物理限制。因此,只能切分CPU的时间片造成假并行的现象。即使是现在有了物理的多核,CPU的时间片也会被切分用以创造超过CPU个数的并行现象。C++的Boost库中的协程并不需要有多个并行的调度实体,调度是在库中模拟完成的。

对多任务需求的解决方案带来了有利和不利的结果。有利的是满足了这个编程需求,这个需求非常大,大部分新的实际应用如果没有这个需求根本不能实现,甚至没有这个需求的推动就没有现代的计算机技术。但同时带来的副作用是,无论需求多么大,只能想办法克服,毕竟需求是技术生存的原动力。还有两个最大的副作用是资源竞争与执行实体的调度。

要在一个只能运行一个代码流水线的CPU模拟运行多个代码流水线,通过设计上层概念,然后分时分配CPU资源,所以CPU被人们称为了资源。设计的上层概念有很多:进程、线程、workqueue、tasklet、softirq,这还仅仅是在Linux中目前仍在使用的代码流水线的概念。在当前的Linux内核中,进程与线程几乎是调度一致的,workqueue、tasklet、softirq等和进程一起参与调度算法的调度,因为不调度就意味着不能被CPU执行。这些代码流的概念服务于不同的用途,例如softirq和tasklet一般用于中断;workqueue一般用于驱动。而它们被调度的方法通常是一个专用的内核线程在后台执行。进程和线程的概念则一般用于用户空间。而在实际的实现上,softirq、tasklet或workqueue都可以被封装到某个线程,如此调度算法在调度的时候只需要认识线程一种结构就好了,精简算法逻辑是有好处的。但是这也仅仅是实现的方式。完全可以让调度算法认识各种不同的代码流概念,从而在调度时区别对待。

我们现在写用户空间的代码时只认识进程和线程,因为用户空间是内核的接口产品,用户空间的程序员对整个世界的认识。例如用户空间看到进程和线程时,大部分程序员都能说出进程与线程的区别。但是在内核空间,线程与进程没有太大的区别(需要理解进程组的概念),在用户空间看到的它们之间的区别其实是被伪造出来的。

既然进程的概念最后“胜出”了,也就是说为了说明现代的进程,必须要说明实现进程所必须付出的代价:进程调度、资源竞争、进程通信、进程关系以及进程概念是如何被制造出来的。

5.1.4 父子关系

进程在Linux中被组织为父子关系,这为管理带来了一定程度的方便,也为编程带来了一些复杂性。这是一个既被人喜欢又被人讨厌的特性。

子进程退出,父进程要调用wait或waitpid函数等待回收子进程的资源,否则子进程就一直以“僵尸”状态存在。这就在业务上带来了不便,例如父进程希望启动子进程后继续执行自己的任务,但是又不得不阻塞调用wait或者waitpid等待子进程的退出,此时就会带来困难,虽然wait会响应信号,用sigalarm能给wait设置超时,但是这样又会带来其他问题。一个偷懒的做法是用signal(SIGCHLD,SIG_IGN)来忽略子进程的信号,从而把这个回收工作交给init进程,但是这么做子进程就脱离了掌控,将无法有效地掌握子进程的状态。也可以考虑使用非阻塞的wait,那么就需要设计一个轮询的机制。

我们在实际的工程中经常遇见的需求是:启动一个子进程,阻塞运行后退出。这时我们最常用的方法是使用system系统调用(这是在严肃工程中最不建议使用的方法)。这个系统调用通常用来执行一个外部的命令,其内部本质上是首先使用fork复制一个子进程,然后子进程execl调用具体的命令来覆盖当前的进程内存,然后waitpid阻塞等待。这个接口虽然方便,但是会有诸多的问题。比如子进程完整地继承了父进程的信号和socket等信息;如果在父进程已经使用signal(SIGCHLD,SIG_IGN),那么在子进程结束时,子进程的返回值不能被waitpid接收。最重要的问题就是system使用的是重量级的fork系统调用,完整地拷贝当前的父进程。类似的封装式的解决方案还有popen,通过打开管道来调用外部命令。popen内部也使用fork系统,而fork使用clone系统调用,所以如果父进程太大就很容易出现内存不足的情况(实际情况与man手册中说的情况有所出入)。手册中说的是当内存不使用fork系统时就不会出现内存不足的情况,然而在实际的使用中,有时候会出现内存不足的情况。所以,popen和system一般会使用vfork实现。

这部分的封装代码是stackoverflow上的某位网友的作品,笔者一直在使用他的修改版,非常稳定。在实际的使用中,很多时候只需要一次打开一个子进程,其中的一些逻辑就可以删除,但是这样也可以直接使用。要实现这样的一个封装,首先要获得最大的可打开的fd数目,示例如下。

上述函数可以获得当前系统支持的最大fd打开数目。在很多情况下自己写的系统不需要如此检测,或者每次只会打开一个子进程,然后等待其退出,就可以简化这部分逻辑。进程启动逻辑如下。

我们对比打开操作与系统的pipe调用的区别,就能发现有两个显著的不同:一是使用了vfork,虚拷贝不会造成内存不足的问题和其他的继承问题;二是每次调用vpopen时都要关闭之前调用打开的子进程,这个功能可以根据需要删除。关闭逻辑如下。

可以看到关闭子进程的时候就是关闭打开的文件句柄和waitpid阻塞等待,所以如果要运行一个命令而不需要结果,完全可以直接调用vpopen,然后直接调用vpclose,程序就会一直阻塞到子进程退出,有pthread的join的效果。这个封装用于替代popen和system,用户也不必自己使用vfork、exec,以及clone系列底层命令编码进程的启动。优化waitpid的过程如下。

一般情况下,使用waitpid操作不会那么顺利,所以应根据实际情况做出如上的修改。这样当waitpid因为其他原因退出时,不至于错过子进程的回收时机,能够避免子进程defunct。还添加了允许子进程运行最长时间的逻辑。对应的可以在vpopen中生成的子进程中添加,进程组代码如下。

如此将生成的子进程以及孙子进程都纳入同一个进程组,并且与父进程不一样,如果父进程在等待子进程时超时,就可以直接强制关闭整个子进程组,而不影响父进程本身。

5.1.5 ptrace系统调用

ptrace系统调用可以强制让一个进程成为另外一个进程的父进程,并且可以深入的对子进程进行流程控制。Linux下有几个常用的工具是基于ptrace系统调用的,大部分用来做普通的程序分析,尤其是内存数据审查和修改。ptrace这个系统调用的功能大致分为两部分:一部分是用来控制调试进程的;另一部分是查看和修改被调试进程的数据的。该系统功能所以几乎涵盖了所有调试正在运行进程的需求。

由于ptrace是以线程为单位的,也就是说如果一个进程有多个线程,父进程就需要分别ptrace所有的线程,如果多线程的程序在设计的时候没有考虑被ptrace,那么在使用ptrace时就要非常小心,一个暂停可能就会影响进程整体的执行。例如一个没有被ptrace的线程在不断地向缓存里写数据,由于你的ptrace调用可能导致ptrace的消费者线程运行速度变慢,导致缓存爆炸。下面给出一个完整的ptrace实例程序,读者可以在机器上直接编译,可逐步修改以理解其中的含义。

这里的逻辑无论是对于ptrace的理解还是对二进制的理解都是非常重要的,二进制的相关知识会在后面的章节详细叙述。

另外ptrace可以被用于入侵系统做注入,ptrace之前的默认行为可以覆盖mmap或者mprotect的权限设置,即使是.text的READONLY域也能被写入,但是后来内核打了pax或者grsec补丁,就能在一定程度上限制这种行为。使用ptrace直接向运行中的进程注册代码是可行的,所以ptrace系统调用可以用来攻防。如果要使用ptrace的功能,建议首先详细且尽可能地使用libptrace库。示例如下。

一般使用libptrace库作为中间件,可以在一定程度上解决可移植性问题,并且提供了对mmaps文件的访问封装和一些加载库之类的常用操作的封装。代码也比较简单,使用者完全可以实现自己的ptrace库。

还有如下一些其他常用的工具依赖ptrace审查进程。

· strace:strace 使用 ptrace 在进程和内核之间做记录,打印进程调用的所有系统调用;

· ltrace:ltrace更重视库函数的调用分析。strace和ltrace配合就可以是一个不错的动态调用分析系统;

· ftrace:大部分发行版都没有带这个工具,它可以跟踪一个二进制内部的函数调用,正好与strace和ltrace互补;

· gdb attach:可以直接使用gdb进行进程attach。 gIPXfUWOvzJgD9YvgzmyPfm3CacmR9wTS5qwplx2po1bHgAeUVk6FNyc8JC2ZAPR

点击中间区域
呼出菜单
上一章
目录
下一章
×