用户工具

站点工具


读书笔记:程序员的自我修养:第1章_温故而知新

1.1 从Hello World说起

  • 程序为什么要被编译器编译了才可以运行?
  • 编译器在把C语言程序转换成可以执行的机器码的过程中做了什么,怎么做的?
  • 最后编译出来的可执行文件里面是什么?除了机器码还有什么?它们怎么存放的,怎么组织的?
  • #include <stdio.h>是什么意思?把stdio.h包含进来意味着什么?C语言库又是什么?它怎么实现的?
  • 不同的编译器(Microsoft VC、GCC)和不同的硬件平台(x86、SPARC、MIPS、ARM),以及不同的操作系统(Windows、Linux、UNIX、Solaris),最终编译出来的结果一样吗?为什么?
  • Hello World程序是怎么运行起来的?操作系统是怎么装载它的?它从哪儿开始执行,到哪儿结束?main函数之前发生了什么?main函数结束以后又发生了什么?
  • 如果没有操作系统,Hello World可以运行吗?如果要在一台没有操作系统的机器上运行Hello World需要什么?应该怎么实现?
  • printf是怎么实现的?它为什么可以有不定数量的参数?为什么它能够在终端上输出字符串?
  • Hello World程序在运行时,它在内存中是什么样子的?

1.2 万变不离其宗

对于系统程序开发者来说,计算机多如牛毛的硬件设备中,有三个部件最为关键,它们分别是中央处理器CPU、内存和I/O控制芯片,这三个部件几乎就是计算机的核心了;对于普通应用程序开发者来说,他们似乎除了要关心CPU以外,其他的硬件细节基本不用关心,对于一些高级平台的开发者来说(如Java、.NET或脚本语言开发者),连CPU都不需要关心,因为这些平台为它们提供了一个通用的抽象的计算机,他们只要关心这个抽象的计算机就可以了。

早期的CPU核心频率和内存的频率一样,直接连接在同一个总线上,I/O设备的速度比CPU慢很多,通过I/O控制器连接在总线上。

后来由于CPU核心频率的提升,导致内存跟不上CPU的速度,产生了与内存频率一致的系统总线,CPU采用倍频的方式与系统总线进行通信。随着图形化操作系统的普及,以及3D游戏和多媒体的发展,图形芯片需要和CPU、内存大量交换数据,人们专门设计了一个高速的北桥芯片。

由于北桥芯片速度非常高,无法处理低速设备,于是人们又设计了专门处理低速设备的南桥芯片。

20世纪90年代的PC机在系统总线上采用的是PCI结构,而在低速设备上采用的是ISA总线。

人们在制造CPU的工艺方面已经达到了物理极限,除非CPU制造工艺有本质的突破,否则CPU的频率将会一直被目前4GHz的“天花板”所限制。

通过增加CPU的数量来提高速度,其中最常见的一种形式就是对称多处理器SMP(Symmetrical Multi-Processing)。

多处理器的成本很高,于是处理器厂商开始考虑将多个处理器“合并在一起打包出售”,处理器之间共享比较昂贵的部件,这就是多核处理器,实际上是SMP的简化版本。

1.3 站得高,望得远

一般将用于管理计算机本身的软件称为系统软件,系统软件可以分成两块,一块是平台性的,比如操作系统内核、驱动程序、运行库和数以千计的系统工具;另外一块是用于程序开发的,比如编译器、汇编器、链接器等开发工具和开发库。

计算机科学领域的任何问题都可以通过增加一个间接的中间层来解决。

每个层次之间都需要相互通信,既然需要通信就必须有一个通信的协议,我们一般将其称为接口,接口的下面那层是接口的提供者,由它定义接口;接口的上面那层是接口的使用者,它使用该接口来实现所需要的功能。

开发工具与应用程序是属于同一个层次的,因为它们都使用一个接口,那就是操作系统应用程序编程接口。

运行库使用操作系统提供的系统调用接口,系统调用接口在实现中往往以软件中断的方式提供。

1.4 操作系统做什么

操作系统的一个功能是提供抽象的接口,另外一个主要功能是管理硬件资源。

多道程序,当某个程序暂时无须使用CPU时,监控程序就把另外的正在等待CPU资源的程序启动。存在最大的问题是程序之间的调度策略太粗糙。

每个程序运行一段时间以后主动让出CPU给其他程序,使得一段时间内每个程序都有机会运行一小段时间,这种程序协作模式叫做分时系统。

多任务系统,操作系统接管了所有的硬件资源,并且本身运行在一个受硬件保护的级别。所有的应用程序都以进程的方式运行在比操作系统权限更低的级别,每个进程都有自己独立的地址空间,使得进程之间的地址空间相互隔离。CPU由操作系统统一进行分配,每个进程根据进程优先级的高低都有机会得到CPU,但是,如果运行时间超出了一定的时间,操作系统会暂停该进程,将CPU资源分配给其他等待运行的进程。这种CPU的分配方式即所谓的抢占式,操作系统可以强制剥夺CPU资源并且分配给它认为目前最需要的进程。

驱动程序可以看作是操作系统的一部分,它往往跟操作系统内核一起运行在特权级,但它又与操作系统内核之间有一定的独立性,使得驱动程序有比较好的灵活性。

1.5 内存不够怎么办

操作系统的多任务功能使得CPU能够在多个进程之间很好地共享,从进程的角度看好像是它独占了CPU而不用考虑与其他进程分享CPU的事情。操作系统的I/O抽象模型也很好地实现了I/O设备的共享和抽象,剩下的就是内存分配了。

在早期的计算机中,程序是直接运行在物理内存上的,所访问的都是物理地址。如果只是运行一个程序,只要程序要求的内存空间不超过物理内存的大小,就不会有问题。但是当运行多个程序时,如何将计算机上有限的物理内存分配给多个程序使用。

直接使用物理内存问题很多:

  • 地址空间不隔离,恶意程序可以很容易改写其他程序的内存数据,以达到破坏的目的;非恶意程序如果有bug,修改了其他程序的数据,会使其他程序奔溃。用户希望他在使用计算机的时候,其中一个任务失败了,至少不会影响其他任务。
  • 内存使用率低,把不允许的程序的数据暂时写到磁盘里,会有大量的数据换入换出。
  • 程序运行的地址不确定

解决的办法就是增加中间层,即使用一种间接的地址访问方法。把程序给出的地址看作是一种虚拟地址,然后通过某些映射的方法,将这个虚拟地址转换成实际的物理地址。

分段可以解决地址空间隔离和程序运行地址不确定的问题,而内存使用效率低可以通过分页来解决,因为程序运行时,在某个时间段内,只是频繁地用到了一小部分数据。

1.6 众人拾柴火焰高

线程,有时被称为轻量级进程,是程序执行流的最小单元。一个标准的线程由线程ID、当前指令指针、寄存器集合和堆栈组成。一个进程由一个到多个线程组成,各个线程之间共享程序的内存空间(包括代码段、数据段、堆等)及一些进程级的资源。

线程可以访问进程内存里的所有数据,甚至包括其他线程的堆栈,实际运用中线程也拥有自己的私有存储空间,包括:

  • 线程局部存储
  • 寄存器

当线程数小于等于处理器数量时(并且操作系统支持多处理器),线程的并发是真正的并发,不同的线程运行在不同的处理器上,彼此之间互不相干。

在单处理器对应多线程的情况下,并发是一种模拟出来的状态。这样的一个不断在处理器上切换不同的线程的行为称之为线程调度。在线程调度中,线程通常拥有至少三种状态,分别是:

  • 运行:此时线程正在执行。
  • 就绪:此时线程可以立刻运行,但CPU已经被占用。
  • 等待:此时线程正在等待某一事件(通常是I/O或同步)发生,无法执行。

现在主流的调度方式尽管各不相同,但都带有优先级调度和轮转法的痕迹。所谓轮转法,即是之前提到的让各个线程轮流执行一小段时间的方法。而优先级调度则决定了线程按照什么顺序轮流执行。在具有优先级调度的系统中,线程都拥有各自的线程优先级。

我们一般把频繁等待的线程称之为I/O密集型线程,而把很少等待的线程称为CPU密集型线程。

在优先级调度下,存在一种饿死的现象,一个线程被饿死,是说它的优先级较低,在它执行之前,总是有较高优先级的线程试图执行,因此这个低优先级线程始终无法执行。当一个CPU密集型的线程获得较高的优先级时,许多低优先级的进程就很可能饿死。

在优先级调度的环境下,线程的优先级改变一般有三种方式:

  • 用户指定优先级
  • 根据进入等待状态的频繁程度提升或降低优先级
  • 长时间得不到执行而提升优先级

一个线程只要等待足够长的时间,其优先级一定会提高到足够它执行的程度。

我们之前讨论的线程调度有一个特点,那就是线程在用尽时间片之后会被强制剥夺继续执行的权利,而进入就绪状态,这个过程叫做抢占,即之后执行的别的线程抢占了当前线程。

在Linux内核中并不存在真正意义上的线程概念。Linux将所有的执行实体(无论是线程还是进程)都称为任务,每一个任务概念上都类似于一个单线程的进程,具有内存空间、执行实体、文件资源等。共享了同一个内存空间的多个任务构成了一个进程,这些任务也就成了这个进程里的线程。

pid_t pid;
if (pid = fork())
{
    ...
}

在fork函数调用之后,新的任务将启动并和本任务一起从fork函数返回。但不同的是本任务的fork将返回新任务pid,而新任务的fork将返回0。

fork并不复制原任务的内存空间,而是和原任务一起共享一个写时复制(Copy on Write, COW)的内存空间。所谓写时复制,指的是两个任务可以同时自由地读取内存,但任意一个任务试图对内存进行修改时,内存就会复制一份提供给修改方单独使用,以免影响到其他的任务使用。

所谓同步,既是指在一个线程访问数据未结束的时候,其他线程不得对同一个数据进行访问。如此,对数据的访问被原子化了。

  • 信号量,包括二元信号量
  • 互斥量
  • 临界区
  • 读写锁
  • 条件变量

同一个信号量可以被系统中的一个线程获取之后由另一个线程释放。而互斥量则要求哪个线程获取了互斥量,哪个线程就要负责释放这个锁,其他线程越俎代庖去释放互斥量是无效的。

一个函数要成为可重入的,必须具有如下几个特点:

  • 不使用任何(局部)静态或全局的非const变量
  • 不返回任何(局部)静态或全局的非const变量的指针
  • 仅依赖调用方提供的参数
  • 不依赖任何单个资源的锁
  • 不调用任何不可重入的函数

CPU就发展出了动态调度,在执行程序的时候为了提高效率有可能交换指令的顺序。同样,编译器在进行优化的时候,也可能为了效率而交换毫不相干的两条相邻指令(如x=1和r1=y)的执行顺序。

volatile关键字试图阻止过度优化:

  1. 阻止编译器为了提高速度将一个变量缓存到寄存器内而不写回。
  2. 阻止编译器调整操作volatile变量的指令顺序。

可见volatile可以完美地解决第一个问题,但是volatile是否也能解决第二个问题呢?答案是不能。因为即使volatile能够阻止编译器调整顺序,也无法阻止CPU动态调度换序。

用户态线程并不一定在操作系统内核里对应同等数量的内核线程,例如某些轻量级的线程库,对用户来说如果有三个线程在同时执行,对内核来说很可能只有一个线程。

  1. 一对一模型

对一对一模型来说,一个用户使用的线程就唯一对应一个内核使用的线程(但反过来不一定,一个内核里的线程在用户态不一定有对应的线程存在)

  1. 多对一模型

线程的切换速度比较快,但是一个用户线程阻塞,所有的线程都将无法执行。

  1. 多对多模型

结合了前面两种模型的优点。

评论

请输入您的评论. 可以使用维基语法:
 
读书笔记/程序员的自我修养/第1章_温故而知新.txt · 最后更改: 2018/09/20 20:24 由 eric