《程序员的自我修养》读书笔记 - 第一章 温故而知新

1.1 从Hello World说起

本书解决什么问题?
对于最简单的C Hello World:

#include <stdio.h>

int main()
{
    printf("Hello World\n");
    return 0;
}
  • 程序为什么要被编译器编译了之后能运行?

  • 编译器把C程序转化成可执行机器码过程中,做了什么?如何做的?

  • 编译出来的可执行文件里面是什么?除了机器码还有什么?如何存放的,怎么组织的?

  • #include <stdio.h>是什么意思?把stdio.h包含进来意味着什么?C语言库又是什么?它怎么实现的?

  • 不同的编译器(Microsoft VC, GCC)和不同的硬件平台(x86, ARM, SPARC, MIPS)以及不同的OS(Windows, Linux, Unix),最终编译出来的结果一样吗?为什么?

  • Hello World程序是怎么运行起来的?OS怎么装载的?它从哪儿开始执行,到哪儿结束?main之前发生了什么,结束后又发生了什么?

  • 如果没有OS,Hello World可以运行吗?如果要在一台没有OS的机器上运行Hello World需要什么?如何实现?

  • printf怎么实现的?为什么可以有不定数量的参数?为什么它能在终端上输出字符串?

  • Hello World程序运行时,它在内存中是什么样的?

本书为想了解这些问题的你准备的。

1.2 万变不离其宗

主要描述硬件体系结构(略)

1.3 站得高,望得远

系统软件:一般用于管理计算机本书的软件,称为系统软件,以区别普通的应用程序。
系统软件分为两块:1)平台性的,如OS内核、驱动程序、运行库和系统工具;2)用于程序开发的,如编译器、汇编器、链接器等开发工具和开发库。
本书着重介绍链接器和库(运行库 + 开发库)相关内容。

计算机系统软件体系结构采用一种分层的结构,有句名言:

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

计算机软件体系结构:

  • 接口
    每个层次之间都要通信,其协议一般称为接口(Interface),接口之下是服务提供者,接口之上是服务调用者。
    每个中间层都是对它下面那层的包装和扩展。中间层的存在,使得应用程序和硬件之间保持相对独立。

  • 应用程序编程接口
    最上层是应用程序,跟开发工具在一个层次,其使用的接口是OS应用程序编程接口(Application Programing Interface)。应用程序接口提供者是运行库,什么样的运行库提供什么样API,如Glibc提供POSIX的API,Windows运行库提供Windows API,最常见的32bit Windows提供的API又称为Win32。

  • 系统调用接口
    运行库使用OS提供的系统调用接口(System call interface),系统调用接口在实现中往往以软件中断(Software Interrupt)方式提供,如Linux 0x80号中断作为系统调用接口,Windows是0x2E。

  • 硬件规格
    OS内核层是硬件接口的使用者,硬件是接口的定义者。硬件接口调用硬件驱动程序,常称为硬件规格(Hardware Specification)

1.4 OS做什么

1)提供抽象的接口;2)管理硬件资源。

一个计算机主要硬件资源:CPU、存储器(内存和硬盘)、I/O设备。

1.4.1 不要让CPU打盹

发展历史:
多道程序 => 分时系统 => 多任务系统 => 进程

目的:
提高CPU利用率,不要让CPU空转,甚至死循环。

1.4.2 设备驱动

提供统一接口,统一硬件访问模式,屏蔽硬件细节。如open、read/write、close,既可以读写文件(操作硬盘),也可以与终端设备、网络设备交互。

1.5 内存不够怎么办

进程的总体目标:希望每个进程从逻辑上看,都可以独占计算机的资源。

  • CPU
    多任务 => CPU在多个进程间共享,从进程角度看是独占了CPU。

  • I/O设备
    通过OS的I/O抽象模型(即统一的硬件访问模式),实现I/O设备的共享和抽象。

  • 内存
    早期程序直接运行在物理内存上,访问物理地址。对于同时运行多个程序的情况,就不太适用,因为:

  1. 地址空间不隔离
    所有程序直接访问物理地址,恶意程序很容易改写其他程序的内存数据,影响其他任务,从而导致整个计算机环境不安全。

  2. 内存使用率低
    缺乏有效内存管理机制,程序执行时,整个程序装入内存执行;然而,内存不足时,就需要换出整个程序,空出内存给其他进程使用。

  3. 程序运行的地址不确定
    因为程序每次装入运行时,需要分配一块足够大的内存区域,其位置不确定。给编程造成了麻烦,因为编程时,访问数据和指令跳转的目标地址很多是固定的,就涉及到程序的重定位问题。

解决思路:增加中间层,提供间接的地址访问方法。把程序给出的地址看作是虚拟地址(Virtual Address),然后通过映射的方法,将虚拟地址转化成实际的物理地址。这样做的好处:程序能访问的物理内存区域相互隔离。

1.5.1 关于隔离

32bit的虚拟地址空间,可以看成是0~2^32 byte,大小是4GB。
地址空间分两种:虚拟地址空间(Virtual Address Space),物理地址空间(Physical Address Space)。

每个进程只能访问自己的地址空间,只有真正装了内存的部分(往往 << 4GB),才是真实有效的,对应实际的物理地址。

1.5.2 分段(Segmentation)

分段基本思路:把一段与程序所需要的内存空间大小的虚拟空间,映射到某个地址空间。
简而言之,就是把虚拟空间按地址范围划分多个段,分别用作不同用途。

注:分段做到了地址隔离,但没有解决内存使用效率的问题。因为分段对内存区域的映射还是按程序为单位,如果内存不足,被换入换出到磁盘的都是整个程序,会造成大量磁盘IO,严重影响速度。

根据程序的局部性原理,想到了分页(Paging)的方法,以提高内存的使用率。

1.5.3 分页(Paging)

  • 虚拟页,物理页与磁盘页
    把地址空间人为地等分成固定大小的页,每一页的大小由硬件决定。或者硬件提供多个选项,OS决定具体选择。

把进程的虚拟地址空间按页分割,常用的数据和代码装载到内存中,不常用的放到磁盘中。当需要用到时,再从磁盘取出。

虚拟空间的页叫虚拟页(VP, Virtual Page),物理内存的页叫物理页(PP, Physical Page),磁盘中的页叫磁盘页(DP, Disk Page)
虚拟空间有些页被映射到同一个物理页,这样就可以实现内存共享。(图中线表示映射关系)

上图Process1的VP2、VP3虚拟页不在(物理)内存中,当进程需要用到这2个页时,硬件会捕捉该消息。这就是页错误(Page Fault)。随后OS接管进程,负责将VP2、VP3从磁盘中读取出来、装入内存,然后在内存中这2个页与VP2、VP3建立映射关系。

以页为单位存取和交换这些数据很方便,硬件本身也是以页为单位的操作方式。

  • MMU
    虚拟存储的实现需要依靠硬件的支持,几乎所有硬件都采用MMU(Memory Management Unit),而MMU通常集成在CPU内部。

CPU访问的的虚拟地址,经过MMU转换成实际的物理地址。

1.6 众人拾柴火焰高

1.6.1 线程基础

  • 什么是线程?
    线程有时被称为轻量级进程(Lightweight Process, LWP),是程序执行的最小单元。一个标准的线程由线程ID、当前指令指针(PC)、寄存器集合和堆栈组成。
    通常,一个进程由一个到多个线程组成,各个线程之间共享程序的内存空间(代码段、数据段、堆等)及一些进程级的资源(已打开的文件描述符等)。

  • 多线程的优势

  1. 某个操作可能会陷入长期等待,等待的线程进入休眠,无法继续执行。多线程可以有效利用等待的时间,其他线程可以正常工作。
  2. 某个操作(常常是计算)会消耗大量时间,如果只有一个线程,程序和用户之间交互会中断。多线程可以让一个线程负责交互,另一个负责计算。
  3. 程序逻辑本身要求并发,如多端下载软件(迅雷、bittorrent)。
  4. 多CPU或多核计算机,本身具备同时执行多个线程的能力,单线程无法发挥计算机的全部算力。
  5. 相对于多进程,多线程在数据共享方面效率高很多。
  • 线程的访问权限
    线程的访问非常自由,可以访问内存所有数据,包括其他线程的堆栈(前提是知道地址)。
    线程的私有存储空间包括:
  1. 栈(无法完全被其他线程访问,但通常可认为是私有的);
  2. 线程局部变量(Thread Local Storyage, TLS)。线程局部存储是某些OS为线程单独提供的私有空间,通常容量有限;
  3. 寄存器(PC等),是执行流的基本数据;

线程之间共享的存储空间包括:

  1. 全局变量;
  2. 堆数据;
  3. 函数里的静态变量;
  4. 程序代码;
  5. 打开的文件,A线程打开的文件,B线程可以读写;
  • 线程调度与优先级
    线程数量 <= 处理器数量时,线程并发是真正的并发,不同线程互不相干;
    线程数量 > 处理器数量时,线程并发受到阻碍,因为至少有一个处理器运行多个线程;

一个处理器上切换不同线程的行为,称为线程调度(Thread Schedule),线程至少有3种状态:

  1. 运行(Running):线程正在执行;
  2. 就绪(Ready):线程可以立刻运行,但CPU已经被占用;
  3. 等待(Waiting):线程正在等待某一事件(IO或同步)发生,无法执行;

时间片(Time Slice)
线程拥有一段可以执行的时间,称为时间片。

线程状态切换:

优先级调度 和 轮转法:
线程的调度方式有很多,但都带有优先级调度(Priority Schedule)和轮转法(Round Robin)。
轮转法:指让各个线程轮流执行一小段时间的方法。
线程优先级:在具有优先级调度的系统中,线程拥有各自的线程优先级(Thread Priority)。高优先级先执行,低优先级线程后执行。

IO密集型线程 和 CPU密集型线程:
频繁等待的线程称为IO密集型线程(IO Bound Thread),很少等待的线程称为CPU密集型线程(CPU Bound Thread)

饿死:
饿死(Starvation)现象,一个线程被饿死,是指它的优先级较低,在执行前,总有较高优先级的线程试图执行,因而无法得到执行。

线程优先级改变方式

  1. 用户指定优先级;
  2. 根据进入等待状态频繁程度提升 或降低优先级;
  3. 长时间得不到执行而被提升优先级;
  • 可抢占线程和不可抢占线程
    线程时间片用尽后,会被强制剥夺执行权利,进入就绪状态,该过程叫抢占(Preemption):之后执行线程抢占了当前线程。

在不可抢占线程中,线程主动放弃执行两种情况:

  1. 当线程试图等待某事件(IO等);
  2. 线程主动放弃时间片;
  • Linux多线程
    Linux下,将所有执行实体(线程 or 进程)都称为任务(Task)。创建一个新任务的方法:
  1. fork 复制当前进程;
  2. exec 程序加载器。使用新的可执行映像覆盖当前可执行映像;
  3. clone 创建子进程并从指定位置开始执行;

fork 产生新任务速度很快,为什么?
因为fork并不复制原任务的内存空间,而是和原任务一起共享一个 写时复制(Copy on Write, COW)的内存空间。写时复制,指两个任务可以同时自由读取内存,但任意一个任务试图对内存进行修改时,内存会复制一份提供给修改方单独使用,以免影响其他任务。

fork只能产生本任务的镜像,因此需要使用exec配合才能启动别的新任务。exec可以用心的可执行映像替换当前的可执行映像,因此fork新任务之后,新任务可用exec执行新的可执行文件。
fork + exec通常用于产生新任务,clone常用来产生新线程。

int clone(int (*fn)(void *), void *child_stack, int flags, void *arg);

clone 特点:可以产生一个新任务,从指定位置开始执行,并且可以选择是否共享当前进程的内存空间和文件。
clone 相当于fork + exec(创建新进程,非复制), vfork(创建共享内存的进程,即线程)。

1.6.2 线程安全

  • 线程安全意义
    多线程程序处于一个多变的环境中,可访问的全局变量和堆数据随时可能被其他线程改变。因此多线程程序并发时,数据的一致性变得很重要。

  • 竞争与原子操作
    多线程同时访问一个共享数据,可能造成恶劣的后果,如2个线程分别执行:

线程1 线程2
i=1;
++i;
--i;

结果可能是0,1或2.
这是因为,多种体系结构下,++i的实现方法如下:

  1. 读取i到某个寄存器X;
  2. X++;
  3. 将X内容存储回i;

一个线程在对i进行自增或自减操作时,另外一个线程也可能同时对i进行操作,从而导致意想不到的结果。

如何解决多线程竞争,导致的数据错乱的问题?
有2种方法:

  1. 使用原子操作;
  2. 使用同步与锁;

通常把单指令的操作称为原子的(Atomic),而单条指令的执行是不会被打断的。
Windows下提供了一套API进行原子操作,称为Interlocked API:

Windows API 作用描述
InterlockedExchange 原子地交互2个值
InterlockedDecrement 原子地减少一个值
InterlockedIncrement 原子地增加一个值
InterlockedXor 原子地进行异或操作

在Linux中,提供了原子变量类型atomic_t,其自增等指令往往是单指令。另外,也有一套原子操作API,详见多线程基础之四:Linux提供的原子锁类型atomic_t

原子操作只适合简单场合,对原子变量进行操作;面对复杂场景,需要使用锁。

  • 同步与锁
    所谓同步(Synchronization),指在一个线程访问数据未结束的时候,其他线程不得对同一个数据进行访问。i.e. 对数据的访问被原子化了。

同步最常见的方法是使用锁(Lock)。线程在访问数据前,先试图获取(Acquire)锁,访问结束后释放(Release)锁。如果在锁已经被其他线程占用时试图获取锁,线程会等待,直到锁重新可用。

最简单的锁:二元信号量(Binary Semaphore),只有两种状态:占用,非占用。
允许多个线程并发访问资源,可用选用多元信号量,即信号量(Semphore)

互斥量(Mute)类似于二元信号量,同时允许仅一个线程访问数据或资源,但区别在于互斥量只能由上锁的线程释放,其他线程释放无效。而二元信号量可以由任意线程释放。

临界区(Critical Section)比互斥量更严格的同步手段。
临界区和互斥量、信号量区别在于:互斥量、信号量在系统的任何进程都是可见的,i.e. 一个进程创建互斥量或信号量,另外一个进程释放锁是合法的。而临界区作用范围仅限于本进程,其他进程无法获取该锁。

获取临界区的锁 -- 进入临界区
临界区代码
释放临界区的锁 -- 退出临界区

读写锁(Read-Write Lock):用于特定场合同步。相比较互斥量和信号量而言,是更小粒度的锁,不会每次都锁住全部资源,而是分为两种:共享的(Shared),独占的(Exclusive)。
多个线程可以同时以共享方式占用读写锁;当有一个线程以独占方式占用读写锁时,其他试图获取锁的线程阻塞。

条件变量(Condition Variable):一种同步手段,作用类似于栅栏。
对于条件变量,线程有两种操作:1)等待条件变量,一个条件变量可以被多个线程等待;2)唤醒条件变量,此时某个或所有等待此条件变量的线程都会被唤醒并继续支持。
关于条件变量,有个重要概念“虚假唤醒”,详见虚假唤醒(spurious wakeup) | 简书

  • 可重入(Reentrant)与线程安全
    重入函数:一个函数被重入,表示这个函数没有执行完成,由于外部因素或内部调用,又一次进入该函数执行。
    一个函数被重入有两种情况:
  1. 多个线程同时执行这个函数; -- 多线程并发
  2. 函数自身(可能是经过多层调用之后)调用自身; -- 递归

一个函数被称为可重入的,表明该函数被重入后,不会产生任何不良后果。如下面sqr就是可重入的:

int sqr(int x)
{
    return x * x;
}

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

  1. 不使用任何(局部)静态或全局非const变量;
  2. 不返回任何(局部)静态或全局的非const变量的指针;
  3. 仅依赖于调用方提供的参数;
  4. 不依赖任何单个资源的锁(mutex等); -- 锁只能确保是线程安全的,不能确保是可重入的
  5. 不调用任何不可重入的函数;
  • 过度优化
    即使正确使用锁,也不一定能保证线程安全,因为编译器技术没有跟上日益增长的并发需求。很多看似无错的代码在优化和并发面前,又产生了麻烦。
    例子1:
x = 0;
Thread1        Thread2
lock();        lock();
x++;           x++;
unlock();      unlock();

上面例子看似x++行为不会被破坏,x值应该是2。然而,如果编译器为了提高x访问速度,把x放到某个寄存器里,而各线程的寄存器是相互独立的,x的值也有可能是1。

例子2:

x = y = 0;
Thread1        Thread2
x = 1;          y = 1;
r1 = y;         r2 = x;

r1,r2看似至少有一个为1,逻辑上不可能同时为0。事实上,r1 = r2 = 0的情况也可能发生。因为CPU有动态调度策略,执行程序时,为了提高效率可能交换指令的顺序,编译器在进行优化的时候,可能为了效率而交换毫不相干的2条相邻指令(如x=1和r1=y)的执行顺序。也就是说,实际执行顺序可能是:

x = y = 0;
Thread1         Thread2
r1 = y;         y = 1;
x = 1;          r2 = x;

这样,r1 = r2 = 0 就完全可能。可以使用volatile关键字试图阻止过度优化。volatile可以做到:

  1. 阻止编译器为了提高速度,将一个变量缓存到寄存器而不写回; -- 要求去指定地址读取内容,而非从高速缓存寄存器
  2. 阻止编译器调整操作volatile变量的指令顺序;

volatile可以完美解决第1)个问题,但不能解决第2)个问题,因为即使阻止了编译器调整顺序,也无法阻止CPU动态调度换序。

例3,来自Singleton模式的double-check,与换序有关的问题:

volatile T* pInst = 0;
T* GetInstance()
{
    if (pInst == NULL)
    {
        lock();
        if (pInst == NULL) pInst = new T;
        unlock();
    }
    return pInst;
}

这段代码乍看没有问题,lock确保线程安全,双重pInst 检查可以减小lock调用开销,又能确保线程安全。但实际上这段代码是有问题的,原因在于CPU的乱序执行。C++ new包含2个步骤:

  1. 分配内存;
  2. 调用构造函数;

因此,pInst = new T包含三个步骤:

  1. 分配内存;
  2. 在内存的位置上调用构造函数;
  3. 将内存的地址赋值给pInst;

这三步中,2和3的顺序可以颠倒,也就是说,可能出现:pInst值NULL,但对象还没构造完成。此时如果另外一个线程调用GetInstall,此时 pInst == NULL为false,这个调用会直接返回尚未构造完成的对象的地址(pINst),有可能造成程序崩溃。

从上面例2,例3可以看到CPU乱序执行能力会让对线程安全的保障异常困难。然而,现在并不存在可移植的阻止换序的方法。通常,调用CPU提供的一条指令:barrier,可以阻止CPU将该指令之前的指令交换到barrier之后,反之亦然。不同体系结构CPU提供的barrier指令名称各不相同,如POWERPC提供一条指令名为lwsync。可以这样保证线程安全:

#define barrier() __asm__ volatile ("lwsync")
volatile T* pInst =  NULL;
T* GetInstall()
{
    if (!pInst)
    {
        lock();
        if (!pInst)
        {
            T* temp = new T;
            barrier(); // 阻止CPU将该之前的指令交换到barrier之后
            pInst = temp;
        }
        unlock();
    }
    return pInst;
}

由于barrier的存在,对象的构造一定在barrier执行之前完成,pInst被赋值时,对象总是完好的。

1.6.3 多线程内部情况

  • 三种线程模型
    线程的并发执行是由多处理器或操作系统调度来实现的。实际情况更复杂,用户实际使用的线程并不是内核线程,而是存在于用户态的用户线程。用户线程并不一定在操作系统内核里对应同等数量的内核线程,例如某些轻量级的线程库。用户有3个线程执行,内核可能只有一个。

用户态多线程库的实现方式:

  1. 一对一模型
    最简单的模型,一个用户使用的线程就唯一对应一个内核使用的线程。(一个内核里的线程在用户态不一定有对应的线程存在)

Linux演示这一过程:

int thread_function(void *)
{ ... }
char thread_stack[4096];

void foo
{
    clone(thread_function, thread_stack, CLONE_VM, 0);
}

缺点:
1)许多OS限制了内核线程的数量,因此一对一线程会让用户的线程数量受到限制;
2)许多OS内核线程调度时,上下文切换开销大,导致用户线程执行效率低;

  1. 多对一模型
    将多个用户线程映射到一个内核线程上,线程之间的切换由用户态代码进行。相当于一对一模型,多对一线程切换要快速很多。另外,对用户线程数量几乎无限制。
    image

缺点:
1)如果其中一个用户线程阻塞,所有线程将无法执行,包括内核线程;
2)多处理器下,增多处理器也不会对多对一模型的线程性能有明显帮助;

  1. 多对多模型
    结合多对一和一对一模型的特点,将多个用户线程映射到少数但不止一个内核线程上。一个用户线程阻塞不会阻塞所有用户线程,因为此时还有别的线程可以被调度执行。
    image

1.7 本章小结

回顾计算机的软硬件基本结构,包括CPU和外网部件的连接方式、SMP与多核、软硬件层次体系结构、如果充分利用CPU及与系统软件十分相关的设备驱动,操作系统、虚拟空间、物理空间、页映射和线程的基础概念。

posted @ 2021-11-01 16:31  明明1109  阅读(177)  评论(0编辑  收藏  举报