1、站得高,望的远
计算机科学领域的任何问题都可以通过增加一个间接的中间层来解决。
这句话几乎概括了计算机系统软件体系结构的设计要点 ,整个体系结构从上到下都是按照严格的层次结构设计的。不仅是计算机系统软件整个体系是这样的,体系里面的每个组件比如操作系统本身,很多应用程序,软件系统甚至硬件结构都是按照这种层次的结构组织和设计的。系统软件体系结构中,各种软件的位置如图1所示。
图1 计算机软件体系结构
每个层次之间都需要相互通信,既然需要通信就必须有一个通信的协议,我们一般都将其称为接口,接口的下面那层是接口的提供者,由它定义接口;接口的上面那层是接口的使用者,它使用该接口来实现所需要的功能。在层次体系中,接口是被精心设计过的,尽量保持稳定不变,那么理论上层次之间只要遵循这个接口,任何一个层都可以被修改或被替换。除了硬件和应用程序,其他都是所谓的中间层,每个中间层都是对它下面的那层的包装盒扩展。正是这些中间层的存在,使得应用程序和硬件之间保持相对的独立。
我们的软件体系中,位于最上层的是应用程序,比如我们平时用到的是网络浏览器,Email等。从整个层次结构上来看,开发工具与应用程序是属于同一个层次的。因为它们都使用一个接口,那就是操作系统应用程序编程接口(Application Programming Interface)。应用程序接口的提供者是运行库,什么样的运行库提供什么样的API,比如Linux下的Glibc库提供POSIX的API;Windows的运行库提供Windows API。
运行库使用操作系统提供的系统调用接口,系统调用接口在实现中往往以中断的方式提供,Linux使用0x80号中断作为系统调用接口。
操作系统内核层对于硬件层来说是硬件接口的使用者,而硬件是接口的定义者,硬件的接口决定了操作系统内核,具体来讲就是驱动程序如何操作硬件,如何与硬件进行通信。
2、操作系统做什么
操作系统的一个功能是提供抽象的接口,另外一个主要功能是管理硬件资源。
操作系统接管了所有的硬件资源,并且本身运行在一个受硬件保护的级别。所有的应用程序都以进程的方式运行在比操作系统权限更低的级别,每个进程都有自己独立的地址空间,使得进程之间的地址空间相互隔离。CPU由操作系统统一进行分配,每个进程根据进程优先级的高低都有机会得到CPU,但是,如果运行时间超出了一定的时间,操作系统会暂停该进程,将CPU的资源分配给其他等待运行的进程。
操作系统作为硬件层的上层,它是对硬件的管理和抽象。对于操作系统上面的运行库和应用程序来说,它们希望看到的是一个统一的硬件访问模式。作为应用程序的开发者,我们不希望在开发应用程序的时候直接读写硬件接口,处理硬件中断等这些繁琐的事情。由于硬件之间千差万别,他们的操作方式和访问方式都有区别。比如我们希望在显示器上画一条直线,对于程序员来说,最好的方式是不管计算机使用的是什么显卡,什么显示器,多少大小多少分辨率,我们都只要调用统一的LineTo函数,具体的实现方式由操作系统来完成。
3、内存不够怎么办
进程的总体目标是希望每个进程从逻辑上来看都可以独占计算机的资源。操作系统的多任务功能使得CPU能够在多个进程之间很好的共享,从进程的角度看好像是它独占了CPU而不用考虑与其他进程分享CPU的事情。操作系统的I/O抽象模型也很好的实现了I/O设备的共享和抽象,剩下的就是内存的分配问题了。
在早期的计算机中,程序是直接运行在物理内存上的,也就是说,程序在运行时所访问的地址都是物理地址。当然,如果一个计算机同时只允许一个程序,那么只要程序要求的内存空间不要超过物理内存的大小,就不会有问题。但事实上为了更有效的利用硬件资源,我们必须同时运行多个程序,那么如何将计算机上有限的物理内存分配给多个程序使用。
假设我们的计算机有128M的内存,程序A运行需要10MB,程序B需要100MB,程序C需要20MB。如果我们需要同时运行程序A和B,那么比较直接的做法是将内存的前10MB分配给程序A,10MB-110MB分配给B,这样就能够实现A和B两个程序同时运行,但这种简单的内存分配策略问题很多:
1、地址空间不隔离:所有程序都直接访问物理地址,程序所使用的内存空间不是相互隔离的。恶意的程序可以很容易改写其他程序的内存数据,以达到破坏的目的;有些非恶意的,但是有臭虫的程序可能不小心修改了其他程序的数据,就会使其他程序也会崩溃,这对于需要安全稳定的计算环境的用户来说是不可容忍的。用户希望他在使用计算机的时候,其他一个任务失败了,至少不会影响其他任务。
2、内存使用效率低:由于没有有效的内存管理机制,通常有一个程序执行时,监控程序就将整个程序装入内存中然后开始执行。如果我们忽然要运行程序C,那么这时候内存空间其实已经不够了,这时候我们可以用一个办法是将其他程序的数据暂时写到磁盘里面,等到要用的时候再读回来。由于程序所需要的空间是连续的,那么这个例子里面,如果我们将程序A换出到磁盘所释放的内存空间是不够的,所以只能将B换出到磁盘中,然后将C读入到内存中开始运行。可以看到整个过程中有大量的数据在换入换出,导致效率十分低下。
3、程序运行的地址不确定:因为程序每次需要装入运行时,我们都需要给它从内存中分配一块足够大的空虚区域,这个空闲区域的位置是不确定的。这给程序的编写造成了一定的麻烦,因为程序在编写时,它所访问数据和指令跳转时的目标地址很多都是固定的,这涉及程序的重定位问题。
解决这几个问题的思路就是使用我们之前提到过的法宝:增加中间层,即使用一种间接的地址访问方法。整个想法是这样的,我们把程序给出的地址看作是一种虚拟地址,然后通过某些映射的方法,将这个虚拟地址转换成实际的物理地址。这样,只要我们能够妥善的控制这个虚拟地址到物理地址的映射过程,就可以保证任意一个程序能够访问的物理内存区域跟另外一个程序相互不重叠,以达到地址空间隔离的效果。
关于隔离
让我们回到程序的运行本质上来。用户程序在运行时不希望介入到复杂的存储器管理过程中,作为普通程序,它需要的是一个简单的执行环节,有一个单一的地址空间,有自己的CPU,好像整个程序占有整个计算机而不关心其他的程序(当然程序间通信的部分除外,因为这是程序主动要求跟其他程序和联系)。地址空间分两种:虚拟地址空鹤物理地址空间。
物理地址空间:实实在在存在的,存在于计算机中,而且对于每一台计算机来说只有唯一的一个,32位的处理器,计算机地址线有32条,那么物理空间最大就有4GB。
虚拟地址空间:虚拟的,人们想象出来的地址空间,其实它并不存在,每个进程都有自己独立的虚拟空间,而且每个进程只能访问自己的地址空间,这样就有效的做到了进程的隔离。
分段
基本思想是把一段与程序所需要的内存空间大小的虚拟空间映射到某个地址空间。比如程序A需要10MB内存,那么我们假设有一个地址0x00000000到0x00A00000的10MB大小的一个假想的空间,也就是虚拟空间,然后我们从实际的物理内存中分配一个相同大小的物理地址,假设是物理地址0x00100000开始到0x00B0000结束的一块空间。然后我们把这两块相同大小的地址空间一一映射,即虚拟空间中的每个字节相对应与物理空间的每个字节。这个映射过程由软件来设置,比如操作系统来设置这个映射函数,实际的地址转换由硬件完成。比如当程序A中访问地址0x00001000时,CPU会将这个地址转换成实际的物理地址0x00101000。那么比如程序A和程序B在运行时,他们的虚拟空间和物理空间映射关系可能如图2所示
图2 段映射机制
分段的方法基本解决了上面提到的3个问题的第一个和第三个。首先它做到了地址隔离,因为程序A和程序B被映射到了两块不同的物理空间区域,它们之间没有任何重叠,如果程序A访问了虚拟空间的地址超出了0x00A0000这个范围,那么硬件就会判断这是一个非法的访问,拒绝这个地址请求,并将这个请求报告给操作系统或者监控程序,由它来决定如何处理。再者,对于每个程序来说,无论它们被分配到的物理地址的哪一个区域,对于程序来说都是透明的,他们不要关心物理地址的变化,它们只要按照从地址0x000000到0x00A00000来编写程序,放置变量,所以程序不再需要重定位。
但是分段的这种方法还是没有解决我们的第二个问题,即内存使用效率的问题。分段对内存区域的映射还是按照程序为单位,如果内存不足,被换入换出到磁盘的都是整个程序,这样势必会造成大量的磁盘访问操作,从而严重影响速度,这种方法还是显着粗糙,粒度比较大。事实上,根据程序的局部性原理,当一个程序在运行时,在某个时间段内,它只是频繁的用到了一小部分数据,也就是说,程序的很多数据其实在一个时间段内都是不会被用到的。人们很自然的想到了更小颗粒度的内存分割和映射的方法,使得程序的局部性原理得到充分利用,大大提高了内存的使用率。这种方法就是分页。
分页
分页的基本方法是把地址空间人为地等分成固定大小的页,每一页的大小由硬件决定,或硬件支持多种大小的页,由操作系统选择决定页的大小。比如Intel Pentium系列处理器执行4KB或者4MB的页大小,那么操作系统可以选择每页大小为4KB,也可以选择每页大小威4MB,但是在同一时刻只能选择一种大小。
下面我们来看一个简单的例子。如图3所示,每个虚拟空间有8页,每页大小为1KB,那么虚拟地址空间就是8KB。我们假设该计算机有13条地址线,即拥有2^13的物理寻址能力,那么理论上物理空间可以达到8KB。但是出于种种原因,实际物理空间其实真正有效的只是前6KB。
当我们把进程的虚拟地址空间按页分割,把常用的数据和代码页装载到内存中,把不常用的代码和数据保存在磁盘里,当需要用到的时候再把它从磁盘里取出来。以图3为例,我们假设有两个进程Process1和Process2,他们进程中的部分虚拟页面被映射到了物理页面,比如VP0,VP1和VP7映射到了PP0,PP2和PP3;而有部分页面却在磁盘中,比如VP2和VP3位于磁盘的DP0和DP1中;另外还有一些页面如VP4,VP5和VP6可能尚未被用到或访问到,它们暂停处于未使用的状态。在这里,我们把虚拟空间的页叫做虚拟页(VP,Virtual Page),把物理内存中的页叫做物理页(PP,Physical Page),把磁盘中的页叫做磁盘页(DP,Disk Page)。
如3所示Process1的VP2和VP3不在内存中,但是当进程需要用到这两个页的时候,硬件会捕获到这个消息,就是所谓的页错误,然后操作系统接管进程,负责将VP2和VP3从磁盘中读出来并且装入内存,然后将内存中的这两个页与VP2与VP3之间建立映射关系。以页为单位来存取和交换这些数据非常方便,硬件本身就支持这种以页为单位的操作方式。
图3 进程虚拟空间,物理空间和磁盘之间的映射关系
保护野生页映射的目的之一,简单地说就是每个页可以设置权限属性,谁可以修改,谁可以访问等,而只有操作系统有权限修改这些属性。
虚拟存储的实现需要依靠硬件的支持,对于不同的CPU来说是不同的,但是几乎所有的硬件都采用了一个叫做MMU(Memory ManagerMent Unit)的部件来进行页映射,如图4所示。在页映射模式下,CPU发出的是Virtual Address,即我们的程序看到的是虚拟地址。经过MMU转换以后就会变成Physical Address。
图4 虚拟地址到物理地址的转换
线程基础
什么是线程
线程有时候被称为轻量级进程,是程序执行的最小单元。一个标准的线程由线程ID,当前指令指针(PC),寄存器集合和堆栈组成。通常意义上,一个进程由一个到多个线程组成,各个线程之间共享程序的内存空间(包括代码段,数据段,堆等)以及一些进程级的资源(如打开的文件和信号)。一个经典的线程与进程的关系如图5所示。
图5进程内的线程
多线程的优点:
1、某个操作可能陷入长时间等待,等待的线程会进入睡眠状态,无法继续执行。多线程执行可以有效利用等待的时间。典型的例子是等待网络响应,这可能要花费数秒甚至数十秒。
2、某个操作(常常是计算)会消耗大量的时间,如果只有一个线程,程序和用户之间的交互会中断。多线程可以让一个线程负责交互,另一个线程负责计算。
3、程序逻辑本身就要求并发操作,例如一个多端下载软件。
4、多CPU或多核计算机本身具备同时执行多个线程的能力,因此单线程程序无法全面地发挥计算机的全部计算能力。
5、相对于多进程应用,多线程在数据共享方面效率要高很多。
线程的访问权限
线程的访问非常自由,它可以自由访问进程内存里的所有数据,甚至包括其他线程的堆栈(如果它知道其他线程的堆栈地址),但实际运用中线程也拥有自己的私有存储空间,包括以下几个方面。
1、栈(尽管并非完全无法被其他线程访问,但一般情况下仍然可以认为是私有数据)。
2、线程局部存储:是操作系统为线程单独提供的私有空间,但通常只具有很有限的容量。
3、寄存器(包括PC寄存器),寄存器是执行流的基本数据,因此是线程私有。
从C程序员的角度来看,数据在线程之间是否私有如表格所示
线程私有 | 线程之间共享(进程所有) |
局部变量 函数的参数 线程局部数据 | 全部变量 堆上的数据 函数里的静态变量 程序代码,任何线程都有权利读取并执行任何代码 打开的文件,A线程打开的文件可以由B线程读写 |
线程调度与优先级
不论是在多处理器的计算机还是在单处理器的计算机上,线程总是“并发”执行的。当线程数量小于等于处理器数量时(并且操作系统支持多处理器),线程的并发是真正的并发,不同的线程运行在不同的处理器上,彼此之间互不相干。但对于线程数量大于处理器数量的情况,线程的并发会受到一些阻碍,因此此时至少有一个处理器会运行多个线程。
在单处理器对应多线程的情况下,并发是一种模拟出来的状态。操作系统会让这些多线程程序轮流执行,每次仅仅执行一小段时间(通常是几十到几百毫秒),这样每个线程就“看起来”在同时执行。这样的一个不断在处理器上切换不同的线程的行为称之为线程调用。在线程调用中,线程通常拥有至少三种状态,分别是:
1、运行:此时线程在执行
2、就绪:此时线程可以立刻运行,但CPU已经被占用
3、等待:此时线程正在等待某一事件(通常是I/O或同步)发生,无法执行
处于运行中线程拥有一段可以执行的时间,这段时间称为时间片,当时间片用尽的时候,该进程将进入就绪状态。如果在时间片用尽之前进程就开始等待某事件,那么它将进入等待状态。每当一个线程离开运行状态时,调用系统就会选择一个其他的就绪线程继续执行。在一个处于等待的线程所等待的事件发生之后,该线程将进入就绪状态。这3个状态的转移如图6所示。
线程状态切换