《程序员的自我修养》读书笔记--第一章

《程序员的自我修养》读书笔记

第一章    温故而知新

1.1 作者提出了一些问题:

  • 程序为什么编译之后才可以运行?
  • 编译器把C语言变成可执行文件的过程中做了什么?怎么做的?
  • 编译出来的可执行文件是什么?
  • #include <stdio.h>是什么意思?C语言库是什么?如何实现?
  • 不同的操作系统、硬件平台、编译器,最终编译出来的结果相同吗?
  • 程序是如何运行起来的?
  • 如果没有操作系统,hello world可以运行吗?如何实现?
  • printf如何实现的?为什么有不定参数?它为什么能够在终端输出字符串?
  • Hello world在运行时,内存是什么样子的?

希望看完这本书之后,能回答这些问题。

 

1.2 万变不离其宗 -- 计算机的基础

 1.2.1 计算机的发展

  (1) 计算机关键的三个部件: 中央处理器CPU、内存、I/O控制芯片

  (2) 最早期的计算机没有复杂的图形功能,CPU的频率也不高,跟内存频率一样,因此都直接连在同一根总线上。

  (3) 随着CPU核心频率的提升,内存跟不上CPU的速度,因此产生了与内存频率一致的系统总线,而CPU采用倍频的方式与系统总线通信。

  (4) 随着图形化操作系统的普及,图形芯片需要跟CPU和内存之间频繁通信,慢速的I/O总线不能满足巨大的需求,于是北桥芯片被设计出来,以便更加告诉的交换数据。

  (5) 由于北桥计算速度非常高,所有相对低速的设备如果都全部链接在北桥上,它既要处理高速设备,又要处理低俗设备,设计就会十分复杂。因此南桥芯片又被设计出来,键盘鼠标等低速设备都连接在南桥上,汇总之后再连接到北桥上。

1.2.2 CPU的发展

    CPU的工艺已经到达了物理极限,短期内不会突破“4GHz”的极限。因此,人们开始想办法增加CPU的数量,这就是多处理器。多处理器在处理大型的数据库、网络服务器的并发请求上能发挥最大的作用,因为这些请求之间往往是相互独立的。

1.3 计算机软件

  计算机系统软件采用一种层结构,即每层往上层提供接口来进行通信,定一个通信协议。  大致有这几层: 硬件(硬件规格) à 操作系统内核(系统调用) à 运行时库(操作系统API) à 应用(浏览器、播放器等)

1.4 操作系统做什么

  操作系统有两个作用: (1)为应用提供抽象的接口 (2)管理硬件资源

1.4.1 不让CPU打盹 – 充分利用CPU

多道程序 --> 分时系统  --> 多任务系统

1.4.2 设备驱动

(1)操作系统通过硬件驱动将硬件抽象成接口,供程序员调用。驱动程序可以看做是操作系统的一部分,它往往跟操作系统内核一起运行在特权级,但它又与操作系统内核之间有一定的独立性,使得驱动程序有比较好的灵活性。

(2)硬盘的结构:基本存储单位为扇区,每个扇区一般为512字节。一个硬盘往往有多个盘片,每个盘片分为两面,每面按照同心圆划分为若干磁道,每个磁道划分为若干扇区。现代的硬盘普遍使用一种叫做LBA(Logical Block Address)的方式,给我们屏蔽了复杂的盘面、磁道之类的概念,只需要关心逻辑的扇区号。

1.5 内存不够怎么办

如何将计算机有限的物理内存分配给多个程序使用?假如计算机有128M内存,A程序需要10M,B程序需要100M。如果同时运行A和B,最简单的办法就是将计算机的0 – 10M分给A,10 – 110M 分给B。但是这样会产生很多问题:

  • l地址空间不隔离。如果越界访问可能会修改别的程序的数据。
  •   内存使用效率低。如果再有C程序需要20M内存,就需要把其它程序的数据暂时写到磁盘中,用的时候再读回来。大量数据换入换出,导致效率十分低下。
  •   程序运行的地址不确定。每次装入运行都需要从内存中分配一块足够大的空闲区域,位置不确定。

1.5.1 关于隔离

    解决上述问题的思路:增加中间层,使用一种间接访问地址的方法。

    把程序给出的地址看做是一种虚拟地址,然后通过某种映射,将这个虚拟地址转化到实际的物理地址。这样,只需要控制好映射过程,就能保证程序所能访问的物理内存区域跟别的程序不重叠,达到空间隔离的效果。

    每个进程都有自己独立的虚拟空间,而且每个进程只能访问自己的地址空间,这样就做到了进程的隔离。

1.5.2 分段

分段就是:把程序所需要的内存大小的虚拟空间映射到某个地址空间。比如A需要10M,就假设有0x0000 0000 到 0x00A0 0000大小的虚拟空间,然后从物理内存分配一个相同大小的空间,比如是0x0010 0000到0x00B0 0000。操作系统来设置这个映射函数,实际的地址转换由硬件完成。如果越界,硬件就会判断这是一个非法访问,拒绝这个地址请求,并上报操作系统或监控程序。

    分段解决了地址隔离的问题,但没有解决内存使用效率的问题。

1.5.3 分页

    分页:把地址空间认为的等分为固定大小的页,每一页的大小由硬件决定,或硬件支持多种大小的页,由操作系统决定页的大小。目前几乎所有的PC上都使用4KB大小的页。

    虚拟空间的几个页有可能被映射到同一物理内存内存页,这样就可以实现内存共享。

    保护也是页映射的目的之一,可以给页设置权限属性,而只有操作系统有权限修改这些属性,那么操作系统就可以保护自己和保护进程。

    虚拟存储的实现需要硬件的支持,几乎所有的硬件都使用MMU(Memory Management Unit)来进行页映射。我们的程序看到的是虚拟内存,经过MMU转换后变成物理地址。一般MMU集成在CPU内部,不会以独立的部件存在。

1.6 众人拾柴火焰高

1.6.1 线程基础

    线程:有时候被称为轻量级进程,是程序执行流的最小单元。一个标准的线程由线程ID,当前指令指针(PC)、寄存器集合和堆栈组成的。通常一个进程由多个线程组成,各线程共享内存空间及一些进程级的资源。

    使用多线程的原因:

  •  某个操作可能陷入长时间等待,这时候线程会进入睡眠状态,无法继续执行。多线程可有效利用等待时间。典型的例子是等待网络响应。
  •   某个操作会消耗大量的时间,如果只有一个线程,程序和用户之间的交互会中断。多线程可以让一个线程进行交互,另一个线程处理任务。
  •   程序本身需要进行并发操作,如多端下载。
  •   多CPU或多核计算机,本身就具有同时执行多线程的能力。单线程无法充分利用计算机的全部计算能力。
  •  相对于多进程应用,多线程在数据共享方面效率要高很多。

线程的访问权限:

实际运用中线程也拥有自己的存储空间,包括以下几方面:

(1)   栈(并非完全无法被其它线程访问,一般情况下认为是私有数据)

(2)   线程局部存储。操作系统为线程单独提供的私有空间,通常只有很有限的容量。

(3)   寄存器(包括PC寄存器),寄存器是执行流的基本数据,因此为线程私有。

程序在线程中是否私有,如下图所示。

 

其中,TLS指的是安全传输协议

 

线程调度与优先级

线程通常拥有至少三种状态:运行、就绪(可立刻运行,但CPU被占用了)、等待(正等待某一事件发生,通常是I/O或同步,无法执行)

    处于运行中的线程拥有一段可以执行的时间,成为时间片,时间片用尽之后,线程进入就绪状态,如果在时间片用尽之前就开始等待,则进入等待状态。每当一个线程离开运行状态时,调度系统就会选一个其它的就绪状态的线程来运行,处于等待状态的线程等待结束后,进入就绪状态。

 

 

    每个线程都自带优先级,用户可以手动设置,操作系统还会根据线程的不同表现调整优先级,使得调度更有效率。

    通常情况下,频繁进入等待状态的线程比频繁进入大量计算、以至于每次都将时间片用尽的线程更受欢迎。前者被称为I/O密集型线程,后者称为CPU密集型线程。I/O密集型线程更容易得到优先级提升。

    饿死现象:一个线程的优先级较低,每次试图执行时总有优先级较高的线程先执行,导致该线程一直无法被执行。如果CPU密集型线程获得较高优先级时,容易造成线程饿死。为了避免饿死现象,调度系统通常会逐步提高那些等待了过长时间的线程。在这样的手段下,只要一个线程等待足够长的时间,就一定会被执行。

可抢占线程和不可抢占线程

抢占:线程用尽时间片之后会强制剥夺执行权利,进入就绪状态。即别的线程抢占了当前线程。

早期的线程是不可抢占的,线程必须手动发送放弃命令,才能让其它线程得到执行。

1.6.2 线程安全

一、竞争与原子操作

    这里有个很典型的问题,线程A执行i= 1;++i;操作,线程B执行--i;操作。在许多体系结构上,++i的实现方法如下:

(1)   将i值读到某个寄存器X上

(2)   X++

(3)   将X的值存储会i

加入A和B并发执行,有可能A线程执行完第一步,B线程开始执行,B线程执行完的i值为0,A线程继续执行,最终结果为i=1.(这个问题都很多种可能,每个步骤顺序不同都会产生不同的结果)

很明显,自增操作在多线程情况下出错是因为,它被汇编之后不止一条指令,因此有可能执行一半就被操作系统打断。我们把单指令的操作称为原子的。

原子操作能保证操作不会出问题,但只能适用比较简单的场景,对于复杂的场景,原子操作就力不从心了。这时候,我们就需要用“锁”。

同步与锁

1、 二元信号量:是最简单的一种锁,只有两种状态,占用与非占用。适用于只能被唯一一个线程独占访问的资源。

2、 信号量:允许多个线程并发访问,一个初始值为N的信号量允许N个线程访问。

3、 互斥量:类似于二元信号量,只允许一个线程访问。但不同的是,信号量可以被系统中的任意线程获取并释放(可以A线程获取,B线程释放)。而互斥量要求哪个线程获取了互斥量,哪个线程就要负责释放。

4、 临界区:比互斥量更加严格的同步手段。临界区和互斥量、信号量的区别是:互斥量和信号量在系统的任何进程都是可见的,也就是说,一个进程创建了一个互斥量或信号量,另一个进程试图去获取是合法的。而临界区的作用范围仅限于本进程,其它进程无法获取该锁。

5、 读写锁:读写锁有两种获取方式:共享和独占。当锁是自由的,无论哪种方式获取都能成功。当锁是共享的,可以立刻以共享方式再次获取,但如果是独占请求,则需要等待锁被释放。当锁是独占的,不论以哪种方式再次获取都不成功,要等待当前线程释放。

6、 条件变量:对应条件变量,线程有两种操作:(1)等待条件变量(一个条件变量可以被多个线程等待)。(2)唤醒条件变量。也就是说:使用条件变量可以让多个线程一起等待某件事的发生,当事件发生时(条件变量被唤醒),所以线程一起恢复执行。

可重入与线程安全

    一个函数被重入,表明函数没有执行完,由于外部原因或内部调用,再次进入执行。

一个函数被重入,只有两种情况:

(1)   多个线程同时执行该函数。

(2)   函数自身调用自身。

一个函数可重入,表明该函数被重入之后无任何不良后果。例如:

    int sqr(int x)

    {

        return x * x;

    }

可重入函数的几个特点:

(1)   不使用任何(局部)静态或全局非const变量。

(2)   不返回任何(局部)静态或全局非const变量。

(3)   仅依赖于调用方法提供的参数。

(4)   不依赖任何单个资源的锁(mutex等)

(5)   不调用任何不可重入的函数。

可重入是并发安全的强力保证,可在多线程环境下放心使用。

过度优化

CPU或系统为提高效率,有可能改变指令顺序,导致我们得不到期待的结果。这时候,我们就可以使用volatile关键字来防止过度优化。它可以做到两件事:

(1)   阻止编译器为提高速度将一个变量缓存到寄存器而不写会。

(2)   阻止编译器调整volatile变量的指令顺序。

但volatile只能阻止编译器调整顺序,而不能阻止CPU动态调整。

阻止CPU换序是必须的,但现在尚无可移植的阻止换序的方法,通常情况下是调用CPU的一条指令barrier。

1.6.3 多线程内部情况

三种线程模型

1、 一对一模型:一个用户使用的线程就唯一对应一个内核使用的线程(反过来不一定,内核线程在用户态不一定有对应的存在)。该模型的优点是:线程之间的并发是真正的并发,一个线程因为某些原因阻塞时,其它线程的执行不会受到影响。一对一模型也可以让多线程程序在多处理器上有更好的表现。

缺点:(1)由于许多操作系统限制了内核线程的数量,因此一对一线程会让用户的线程数量受到限制。(2)许多操作系统内核线程调度时,上下文切换的开销较大,导致用户线程的执行效率下降。

2、 多对一模型:将多个用户态线程映射到一个内核态线程上,线程之间的切换由用户态的代码来进行,因此相对于一对一,该模型切换要快速许多。优势是高效的上下文切换和几乎无限制的线程数量。

多对一模型的一个问题是:如果一个用户线程阻塞,那么所有的线程都无法执行,因为此时内核里的线程也随之阻塞了。

 

3、 多对多模型:将多个用户线程映射到少数但不止一个内核线程上。在这种模式下,一个用户态线程的阻塞不会使得所有用户态线程阻塞,因为此时还有别的线程被调度执行。另外,用户线程数量也没什么限制,在多处理系统上,多对多模型的线程也能得到一定性能的提升,不过提升的幅度没有一对一多。

   

 

posted @ 2017-08-11 21:07  安月月  阅读(262)  评论(0编辑  收藏  举报