工作久了,我发现了这样一个事实:身边不乏有掌握了好几门编程语言的程序员,但很少有对计算机基础知识了如指掌的程序员。学好了一门编程语言,大多数人就可以去找到一份养家糊口的工作了,而如果你说自己对计算机基础知识很了解,恐怕没有几个雇主会因此而雇佣你。随着工作年限的增加,很多人发现即使不懂计算机的组成及内部结构,不懂程序的运行原理,依然可以轻松地应付工作。正因为这样,大部分程序员实际上并不愿意花时间来学习(或者是温习)计算机的基础知识。但是,正所谓“温故而知新,可以为师矣”,虽然学习这些计算机的基础知识不能够立即为我们带来现实的回报(比如升职加薪),但这对我们提升自我修养、提高工作效率是有很大帮助的。
最近,在工作之余,我阅读了《程序员的自我修养》一书(俞甲子/石凡/潘爱民著,电子工业出版社,2009年4月)的第一章,感觉这正是自己想要了解的、也还欠缺的有关计算机基础的认识。本章对整个计算机的软硬件基本结构进行了回顾,包括CPU与外围部件的连接方式、SMP与多核、软硬件层次体系结构、如何充分利用CPU及与系统软件十分相关的设备驱动、操作系统、虚拟空间、物理空间、页映射和线程的基本概念。在阅读的过程中,我将该章的关键内容列举了出来,形成了下面的90个知识点,供大家学习参考。我个人读完这些知识点之后还是觉得收获挺大的,希望你也有和我一样的感觉!
1.计算机有3个部件最为关键:中央处理器CPU、内存和I/O控制芯片。
2.对称多处理器(SMP)就是每个CPU在系统中所处的位置和所发挥的功能都是一样的,是相互对称的。
3.处理器的厂商将多个处理器“合并在一起打包出售”,这些“被打包”的处理器之间共享比较昂贵的缓存部件,只保留多个核心,并且以一个处理器的外包装进行出售,售价比单核心的处理器只贵了一点,这就是多核处理器的基本想法;多核处理器实际上就是SMP的简化版。
4.系统软件可以分成两块:一块是平台性的(如操作系统内核、驱动程序、运行库和数以千计的系统工具),另外一块是用于程序开发的(如编译器、汇编器、链接器等开发工具和开发库)。
5.计算机科学领域的任何问题都可以通过增加一个间接的中间层来解决。
6.每个层次之间都须要相互通信,既然须要通信就必须有一个通信的协议,一般将其称为接口(Interface)。
7.每个中间层都是对它下面的那层的包装和扩展。
8.从整个层次结构上来看,开发工具和应用程序是属于同一个层次的,因为它们都使用一个接口,那就是操作系统应用程序编程接口(API)。API的提供者是运行库,什么样的运行库就提供什么样的API。
9.运行库使用操作系统提供的系统调用接口(System Call Interface),系统调用接口在实现中往往以软件中断的方式提供。
10.操作系统内核层对于硬件层来说是硬件接口的使用者,而硬件是接口的定义者,硬件的接口定义决定了操作系统内核,具体来讲就是驱动程序如何操作硬件,如何与硬件进行通信;这种接口往往被叫做硬件规格。
11.操作系统的一个功能是提供抽象的接口,另外一个主要功能是管理硬件资源。
12.计算机硬件的能力是有限的,充分挖掘硬件的能力,使得计算机运行得更有效率,在更短的时间内处理更多的任务,才是我们的目标。
13.一个计算机中的资源主要分CPU、存储器(包括内存和硬盘)和I/O设备。
14.当某个程序暂时无须使用CPU时,监控程序就把另外的正在等待CPU资源的程序启动,使得CPU能够充分地利用起来,这被称为多道程序的方法。
15.每个程序运行一段时间以后都主动让出CPU给其他程序,使得一段时间内每个程序都有机会运行一小段时间,这种程序协作模式叫做分时系统。
16.在多任务系统中,操作系统接管了所有的硬件资源,并且本身运行在一个受硬件保护的级别,所有的应用程序都以进程的方式运行在比操作系统权限更低的级别,每个进程都有自己独立的地址空间,使得进程之间的地址空间相互隔离。
17.CPU由操作系统统一进行分配,每个进程根据进程优先级的高低都有机会得到CPU,但是,如果运行时间超出了一定的时间,操作系统会暂停该进程,将CPU资源分配给其他等待运行的进程,这种CPU的分配方式即所谓的抢占式。
18.操作系统可以强制剥夺CPU资源并且分配给它认为目前最需要的进程;如果操作系统分配给每个进程的时间都很短,即CPU在多个进程间快速地切换,从而造成了很多进程都在同时运行的假象。
19.繁琐的硬件细节全都交给操作系统中的硬件驱动程序来完成。
20.驱动程序可以看作是操作系统的一部分,它往往跟操作系统内核一起运行在特权级,但它又与操作系统内核之间有一定的独立性,使得驱动程序有比较好的灵活性。
21.硬盘的基本存储单位是扇区,每个扇区一般为512字节;一个硬盘往往有多个盘片,每个盘片分两面,每面按照同心圆划分为若干个磁道,每个磁道划分为若干个扇区。
22.现代的硬盘普遍使用一种叫做LBA的方式,即整个硬盘中所有的扇区从0开始编号,一直到最后一个扇区,这个扇区编号叫做逻辑扇区号。
23.文件系统保存了这些文件的存储结构,负责维护这些数据结构并且保证磁盘的扇区能够有效地组织和利用。
24.当在Linux操作系统中需要读取一个文件的前4096个字节时,会使用一个read的系统调用来实现。
25.文件系统收到read请求之后,判断出文件的前4096个字节位于磁盘的1000号逻辑扇区到1007号逻辑扇区,然后文件系统就向硬盘驱动发出一个读取逻辑扇区为1000号开始的8个扇区的请求,磁盘驱动程序收到这个请求以后就向硬盘发出硬件命令。
26.向硬件发送I/O命令的方式有多种,其中最为常见的一种就是通过读写I/O端口寄存器来实现。
27.在x86平台上,共有65536个硬件端口寄存器,不同的硬件被分配到了不同的I/O端口地址。
28.CPU提供了两条专门的指令“in”和“out”来实现对硬件端口的读和写。
29.对于IDE接口来说,它有两个通道,分别为IDE0和IDE1,每个通道上可以连接两个设备,分别为Master和Slave,一个PC中最多可以有4个IDE设备。
30.在早期的计算机中,程序是直接运行在物理内存上的,也就是说,程序在运行时所访问的地址都是物理地址。
31.简单的内存分配策略问题很多:第一,地址空间不隔离;第二,内存使用效率低;第三,程序运行的地址不确定。
32.解决内存问题的思路就是增加中间层,即使用一种间接的地址访问方法。我们把程序给出的地址看作是一种虚拟地址,然后通过某些映射的方法,将这个虚拟地址转换成实际的物理地址;只要我们能够妥善地控制这个虚拟地址到物理地址的映射过程,就可以保证任意一个程序所能够访问的物理内存区域跟另一个程序互相不重叠,以达到地址空间隔离的效果。
33.用户程序在运行时不希望介入到这些复杂的存储器管理过程中,作为普通的程序,它需要的是一个简单的执行环境,有一个单一的地址空间、有自己的CPU,好像整个程序占有整个计算机而不用关心其它程序。
34.所谓的地址空间是一个字节,而这个数组大小由地址空间的地址长度决定,比如32位的地址空间的大小为4GB。
35.地址空间分两种:虚拟地址空间和物理地址空间;物理地址空间是实实在在存在的,存在于计算机中,而且对于每一台计算机来说只有唯一的一个;虚拟地址空间是指虚拟的、人们想象出来的地址空间,其实它并不存在,每个进程都有自己独立的虚拟空间,而且每个进程只能访问自己的地址空间,这样就有效地做到了进程的隔离。
36.分段的方法的基本思路是把一段与程序所需要的内存空间大小相等的虚拟空间映射到某个地址空间。该方法基本解决了两个问题:首先它做到了地址隔离,因为两个程序被映射到了两块不同的物理空间区域,它们之间没有任何重叠;再者,对于每个程序来说,无论它们被分配到物理地址的哪一个区域,对于程序来说都是透明的,它们不需要关心物理地址的变化,所以程序不再需要重定位。
37.分段的方法没有解决内存使用效率的问题,分段对内存区域的映射是按照程序为单位的,如果内存不足,被换入换出到磁盘的都是整个程序,这样势必造成大量的磁盘访问操作,从而严重影响速度。
38.分页的基本方法是把地址空间人为地等分成固定大小的页,每一页的大小由硬件决定,或硬件支持多种大小的页,由操作系统选择决定页的大小;目前几乎所有的PC上的操作系统都使用4KB大小的页。
39.把虚拟空间的页就叫虚拟页VP,把物理内存中的页叫做物理页PP,把磁盘中的页叫做磁盘页DP。
40.保护也是页映射的目的之一,简单地说就是每个页可以设置权威属性,而只有操作系统有权限修改这些属性,那么操作系统就可以做到保护自己和保护进程。
41.虚拟存储的实现需要依靠硬件的支持,对于不同的CPU来说是不同的,但几乎所有的硬件都采用一个叫做MMU的部件来进行页映射;在页映射模式下,CPU发出的是虚拟地址,经过MMU转换以后就变成了物理地址;一般MMU都集成在CPU内部了,不会以独立的部件存在。
42.线程,有时被称为轻量级进程LWP,是程序执行流的最小单位;一个标准的线程由线程ID、当前指令指针(PC)、寄存器集合和堆栈组成。
43.一个进程由一个到多个线程组成,各个线程之间共享程序的内存空间(包括代码段、数据段、堆等)及一些进程级的资源(如打开文件和信号)。
44.使用多线程的原因有以下几点:
第一,某个操作可能会陷入长时间等待,等待的线程会进入睡眠状态,无法继续执行,多线程执行可以有效利用等待的时间。
第二,某个操作会消耗大量的时间,如果只有一个线程,程序和用户之间的交互会中断。
第三,程序逻辑本身就要求并发操作。
第四,多CPU或多核计算机,本身具备同时执行多个线程的能力。
第五,相对于多进程应用,多线程在数据共享方面效率要高很多。
45.线程的访问非常自由,它可以访问进程内存里的所有数据,甚至包括其他线程的堆栈,但实际运用中线程也拥有自己的私有存储空间,包括以下几方面:
第一,栈。
第二,线程局部存储TLS。它是某些操作系统为线程单独提供的私有空间,但通常只具有很有限的容量。
第三,寄存器。它是执行流的基本数据,因此为线程私有。
46.从C程序员的角度来看,线程私有的数据包括:局部变量、函数的参数和TLS数据;线程之间共享(进程所有)的数据包括:全局变量、堆上的数据、函数里的静态变量、程序代码、打开的文件。
47.不论在多处理器还是单处理器的计算机上,线程总是“并发”执行的;当线程数量小于等于处理器数量时(并且操作系统支持多处理器),线程的并发是真正的并发,不同的线程运行在不同的处理器上,彼此之间互不相干。
48.不断在处理器上切换不同的线程的行为称之为线程调度;在线程调度中,线程通常拥有至少三种状态:
第一,运行:此时线程正在执行。
第二,就绪:此时线程可以立刻运行,但CPU已经被占用。
第三,等待:此时线程正在等待某一事件(通常是I/O或同步)发生,无法执行。
49.处于运行中线程拥有一段可以执行的时间,这段时间称为时间片;当时间片用尽的时候,该进程将进入就绪状态;如果在时间片用尽之前进程就开始等待某事件,那么它将进入等待状态;每当一个线程离开运行状态时,调度系统就会选择一个其它的就绪线程继续执行;在一个处于等待状态的线程所等待的事件发生之后,该线程将进入就绪状态。
50.主流的线程调度方式优先级调度和轮转法。
51.在Windows中,可以通过使用SetThreadPriority函数来设置线程的优先级,而Linux下与线程相关的操作可通过pthread库来实现。
52.在Windows和Linux中,线程的优先级不仅可以由用户手动设置,系统还会根据不同线程的表现自动调整优先级,以使得调度更有效率。
53.一般把频繁等待的线程称之为IO密集型线程,而把很少等待的线程称为CPU密集型线程;IO密集型线程总是比CPU密集型线程容易得到优先级的提升。
54.在优先级调度下,存在一种饿死的现象,一个线程被饿死,是说它的优先级较低,在它执行之前,总是有较高优先级的线程试图执行,因此这个低优先级线程始终无法执行。
55.为了避免饿死现象,调度系统常常会逐步提升那些等待了过长时间的得不到执行的线程的优先级。
56.在优先级调度环境下,线程的优先级改变一般有三种方式:
第一,用户指定优先级;
第二,根据进入等待状态的频繁程度提升或降低优先级。
第三,长时间得不到执行而被提升优先级。
57.线程在用尽时间片之后会被强制剥夺继续执行的权利,而进入就绪状态,这个过程叫做抢占,即之后执行的别的线程抢占了当前线程。
58.在不可抢占线程中,线程主动放弃执行无非两种情况:
第一,当线程试图等待某事件(I/O等)时。
第二,线程主动放弃时间片。
59.在Windows API中,可以使用明确的API:CreateProcess和CreateThread来创建进程和线程,并且有一系列的API来操纵它们。
60.在Linux内核中并不存在真正意义上的线程概念,它将所有的执行实体(无论是线程还是进程)都称为任务,每一个任务概念上都类似于一个单线程的进程,具有内存空间、执行实体、文件资源等。
61.Linux下不同的任务之间可以选择共享内存空间,因而在实际意义上,共享了同一个内存空间的多个任务构成了一个进程,这些任务也就成了这个进程里的线程。
62.在Linux下,用以下方法可以创建一个新的任务:
第一,fork:复制当前进程。
第二,exec:使用新的可执行映像覆盖当前可执行映像。
第三,clone:创建子进程并从指定位置开始执行。
63.fork函数产生一个和当前进程完全一样的新进程,并和当前进程一样从fork函数里返回。
64.fork产生新任务的速度非常快,因为fork并不复制原任务的内存空间,而是和原任务一起共享一个写时复制的内存空间;所谓写时复制,指的是两个任务可以同时自由地读取内存,但任意一个任务试图对内存进行修改时,内存就会复制一份提供给修改方单独使用,以免影响到其它的任务使用。
65.fork只能够产生本任务的镜像,因此须要使用exec配合才能启动别的新任务。
66.fork和exec通常用于产生新任务,而如果要产生新线程,则可以使用clone。
67.使用clone可以产生一个新的任务,从指定的位置开始执行,并且(可选的)共享当前进程的内存空间和文件等。
68.把单指令的操作称为原子的,因为无论如何,单条指令的执行是不会被打断的。
69.为了避免多个线程同时读写同一个数据而产生不可预料的后果,需要将各个线程对同一个数据的访问同步;所谓同步,是指在一个线程访问数据未结束的时候,其它线程不得对同一个数据进行访问。
70.同步的最常见方法是使用锁,它是一种非强制机制,每一个线程在访问数据或资源之前首先试图获取锁,并在访问结束之后释放锁;在锁已经被占用的时候试图获取锁时,线程会等待,直到锁重新可用。
71.二元信号量是最简单的一种锁,它只有两种状态:占用与非占用;它适合只能被唯一一个线程独占访问的资源。
72.对于允许多个线程并发访问的资源,多元信号量简称信号量;一个初始值为N的信号量允许N个线程并发访问。
73.线程访问资源的时候首先获取信号量,进行如下操作:
第一,将信号量的值减1;
第二,如果信号量的值小于0,则进入等待状态,否则继续执行。
访问完资源之后,线程释放信号量,进行如下操作:
第一,将信号量的值加1;
第二,如果信号量的值小于1,唤醒一个等待中的线程。
74.互斥量(Mutex)和二元信号量很类似,资源仅同时允许一个线程访问,但和信号量不同的是,信号量在整个系统可以被任意线程获取并释放,而互斥量则要求哪个线程获取了互斥量,哪个线程就要负责释放这个锁。
75.临界区是比互斥量更加严格的同步手段,把临界区的锁的获取称为进入临界区,而把锁的释放称为离开临界区;临界区和互斥量与信号量的区别在于,互斥量和信号量在系统的任何进程里都是可见的,而临界区的作用范围仅限于本进程。
76.读写锁致力于一种更加特定的场合的同步,对于同一个锁,读写锁有两种获取方式,共享的和独占的;如果读写锁的状态是自由的,那么线程以共享方式或独占方式获取都是成功的;如果读写锁的状态是共享的,那么线程以共享方式获取是成功的,而以独占方式获取则必须等待;如果读写锁的状态是独占的,那么线程以共享方式或独占方式获取都必须等待。
77.条件变量作为一种同步手段,作用类似于一个栅栏;对于条件变量,线程可以有两种操作:首先线程可以等待条件变量,一个条件变量可以被多个线程等待;其次,线程可以唤醒条件变量,此时某个或所有等待此条件变量的线程都会被唤醒并继续执行。
78.使用条件变量可以让许多线程一起等待某个事件的发生,当事件发生时(条件变量被唤醒),所有的线程可以一起恢复执行。
79.一个函数被重入,表示这个函数没有执行完成,由于外部因素或内部调用,又一次进入该函数执行;一个函数要被重入,只有两种情况:
第一,多个线程同时执行这个函数;
第二,函数自身(可能是经过多层调用之后)调用自身。
80.一个函数要成为可重入的,必须具有如下几个特点:
第一,不使用任何(局部)静态或全局的非const变量;
第二,不返回任何(局部)静态或全局的非const变量的指针;
第三,仅依赖于调用方提供的参数;
第四,不依赖任何单个资源的锁(mutex等)。
第五,不调用任何不可重入的函数。
81.可重入是并发安全的强力保障,一个可重入的函数可以在多线程环境下放心使用。
82.可以使用volatile关键字试图阻止过度优化,它基本可做两件事情:
第一,阻止编译器为了提高速度将一个变量缓存到寄存器内而不写回;
第二,阻止编译器调整操作volatile变量的指令顺序。
83.一条barrier指令会阻止CPU将该指令之前的指令交换到barrier之后,反之亦然;换句话说,barrier指令的作用类似于一个拦水坝,阻止换序“穿透”这个大坝。
84.大多数操作系统(包括Windows和Linux)都在内核里提供线程的支持,内核线程由多处理器或调度来实现并发;用户实际使用的线程并不是内核线程,而是存在于用户态的用户线程;用户态线程并不一定在操作系统内核里对应同等数量的内核线程。
85.用户态多线程库的实现方式:一对一模型、多对一模型、多对多模型。
86.对一对一模型来说,一个用户使用的线程就唯一对应一个内核使用的线程(但一个内核里的线程在用户态不一定有对应的线程存在);这样线程之间的并发是真正的并发,该模型也可以让多线程程序在多处理器的系统上有更好的表现;一般直接使用API或系统调用创建的线程均为一对一的线程;在Linux里使用clone产生的线程就是一个一对一线程,在Windows里,使用API CreateThread即可创建一个一对一线程。
87.一对一线程的缺点有两个:
第一,由于许多操作系统限制了内核线程的数量,因此一对一线程会让用户的线程数量受到限制;
第二,许多操作系统内核线程调度时,上下文切换的开销较大,导致用户线程的执行效率下降。
88.多对一模型将多个用户线程映射到一个内核线程上,线程之间的切换由用户态的代码来进行,因此相对于一对一线程,多对一模型的线程切换要快速许多;该模型的一大问题是,如果其中一个用户线程阻塞,那么所有的线程都将无法执行,因为此时内核里的线程也随之阻塞了;该模型得到的好处是高效的上下文切换和几乎无限制的线程数量。
89.多对多模型将多个用户线程映射到少数但不止一个内核线程上。
90.在多对多模型中,一个用户线程阻塞并不会使得所有的用户线程阻塞,因为此时还有别的线程可以被调度来执行;另外,多对多模型对用户线程的数量也没有什么限制。