avatar

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

第一章 温故而知新

从 Hello World 说起

1
2
3
4
5
6
# include <studio.h>

int main(){
printf(“Hello World\n”);
return 0;
}

对于下列问题,能立刻反应出清晰又明确的答案吗?

希望我看完后可以,以下为我的第一反应答案

  • 程序为什么要被编译器编译了之后才可以运行?
    因为程序不能被CPU直接识别。
  • 编译器在把C语言转换成可以执行的机器码的过程中做了什么?怎么做的?
    识别程序文法并翻译成汇编语言,然后再转化成机器码。
  • 最后编译出来的可执行文件里面是什么?除了机器码还有什么?它们怎么存放的?它怎么实现的?
    里面是机器码、魔数、各种表、字符串等。利用表存放在可执行文件的各个地方。
  • #include<studio.h>是什么意思?把studio.h包含进来意味着什么?它们怎么存放的?
    include关键字用于导入文件。包含进来意味着导入的文件名为studio.h。存放在c语言环境中,导入进来后直接在该代码行位置展开。
  • 不同的编译器(Microsoft VC、GCC)和不同的硬件平台(x86、SPARC、MIPS、ARM),以及不同的操作系统(Windows、Linux、UNIX、Solaris),最终编译出来的结果一样吗?为什么?
    编译器可能一样,硬件平台不一样,操作系统不一样。因为不同硬件对机器码的识别不同,不同操作系统的可执行文件格式不一样。
  • Hello World 程序是怎么运行起来的?它从哪儿开始执行,到哪儿结束?main函数之前发生了什么?main函数结束以后又发生了什么?
    经过编译,链接之后可以执行。从libc_start_main开始。main函数之前经过了各种初始化,main函数结束之后经历了内存的资源的释放操作。
  • 如果没有操作系统,Hello World可以运行吗?如果要在一台没有操作系统的机器上运行Hello World需要什么?应该怎么实现?
    不可以直接运行。需要直接调配硬件资源去执行自己的程序。
  • printf是怎么实现的?它为什么可以有不定数量的参数?为什么它能够在终端上输出字符串?
    printf是通过向一块内存内写入字符串来向终端中输出字符的。
  • Hello World程序在运行时,它在内存中是什么样子的?
    代码段、数据段并链接到了系统中的库。

站得高,望得远

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

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

每个层次之间都需要相互通信,既然要通信就必须有一个通信的协议,我们一般将其称为接口(Interface),接口的下面那层是接口的提供者,由它定义接口;接口的上面那层是接口的使用者,它使用该接口来实现所需要的功能。除了硬件和应用程序,其他都是所谓的中间层,每个中间层都是对它下面的那层的包装和扩展。

从整个层次结构上看,开发工具和应用程序是属于同一个层次的,因为它们都使用操作系统应用程序编程接口(Application Programming Interface)

操作系统做什么

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

不要让CPU打盹

  • 多道程序:当某个程序暂时无须使用CPU时,监控程序就把另外的正在等待CPU 资源的程序启动,使得CPU能够充分的利用起来。
  • 分时系统:每个程序运行一段时间以后都主动让出CPU给其他程序,使得一段时间内每个程序都有机会运行一小段时间。
  • 多任务系统:操作系统接管了所有的硬件资源,并且本身运行在一个受硬件保护的级别。所有的应用程序都以进程(Process)的方式运行在比操作系统权限更低的级别,每个进程都有自己独立的地址空间,CPU由操作系统统一进行分配,且操作系统可以强制剥夺CPU资源并且分配给它认为目前最需要的进程。

设备驱动

操作系统作为硬件层的上层,它是对硬件的管理和抽象。

由操作系统中的硬件驱动程序来完成繁琐的硬件细节。

内存不够怎么办

如何将计算机上有限的物理内存分配给多个程序使用?

直接分配

例如我们由128MB内存,程序A运行需要10MB,程序B运行需要100MB,如果我们要同时运行A和B,就将内存的前10MB分配给程序A,10MB-110MB分配给程序B。
问题:

  • 地址空间不隔离
  • 内存使用效率低
  • 程序运行的地址不稳定

分段

基本思路是把一段与程序所需要的内存空间大小的虚拟空间映射到某个地址空间。程序中只用管虚拟空间,而实际的地址映射和转换由操作系统和硬件完成。

解决了上文的第一个和第三个问题。

分页

分页的基本方法是把地址空间人为地等分成固定大小的页,每一页的大小由硬件决定,或硬件支持多种大小的页,由操作系统决定页的大小。

我们把进程的虚拟地址空间按页分割,把常用的数据和代码页装载到内存中,把不常用的代码和数据保存在磁盘里,当需要用到的时候再把它从磁盘里取出来即可。

虚拟存储的实现需要依靠硬件的支持,对于不同的CPU来说是不同的。但是几乎所有硬件都采用一个叫MMU(Memory Management Unit)的部件来进行页映射。

1
CPU —Virtual Address—> MMU —Physical Address—> Physical Memory

在页映射模式下,CPU发出的是Virtual Address,即我们的程序看到的是虚拟地址,经过MMU转换以后就变成了Physical Address。

众人拾柴火焰高

线程基础

什么是线程

线程(Thread),有时被称为轻量级进程(Lightweight Process,LWP),是程序执行流的最小单元。一个标准的线程由线程ID、当前指令指针(PC)、寄存器集合和堆栈组成。

一个进程由一个到多个线程组成,各个线程之间共享程序的内存空间(包括代码段、数据段、堆等)及一些进程级的资源(如打开文件和信号)。
使用多线程的原因:

  • 某个操作可能会陷入长时间的等待,等待的线程会进入睡眠状态,无法继续执行,多线程可以有效利用等待时间。
  • 某个操作(常常是计算)会消耗大量的时间,如果只有一个线程,程序和用户之间的交互会中断。多线程可以让一个线程负责交互,另一个线程负责计算。
  • 程序逻辑本身就要求并发操作,例如一个多端下载软件(Bittorrent)。
  • 多CPU或多核计算机,本身具备同时执行多个线程的能力,单线程程序无法发挥计算机的全部计算能力。
  • 相对于多进程应用,多线程在数据共享方面效率要高很多。

线程的访问权限

线程的访问非常自由,它可以访问进程内存里的所有数据,甚至包括其他线程的堆栈(如果它知道其他线程的堆栈地址),但实际运用中线程也拥有自己的私有存储空间:

  • 栈(尽管并非完全无法被其他线程访问,但一般情况下仍然可以认为是私有的数据)。

  • 线程局部存储(Thread Local Storage,TLS)。线程局部存储是某些操作系统为线程单独提供的私有空间,但通常只具有很有限的容量。

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

    从C语言的角度看,数据在线程之间是否私有如下所示:

    线程私有: 线程之间共享:
    局部变量 全局变量
    函数的参数 堆上的数据
    TLS数据 函数里的静态变量
    程序代码,任何线程都有权利读取并执行任何代码
    打开的文件,A线程打开的文件可以由B线程读写

线程调度与优先级

在线程数量小于等于处理器数量时(并且操作系统支持多处理器),线程的并发是真正的并发,不同的线程运行在不同的处理器上,彼此之间互不相干。
在单处理器对应多线程的情况下,并发是一种模拟出来的状态。操作系统会让这些多线程程序轮流执行,每次仅执行一小段时间,这样每个线程就“看起来”在同时执行。这样的一个不断在处理器傻姑娘切换不同线程的行为称之为线程调度(Thread Schedule)。在线程调度中,线程通常拥有至少三种状态:

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

处于运行中线程又一段可以执行的时间,这段时间称为时间片(Time Slice),当时间片用尽的时候,该进程将进入就绪状态。

现在主流的调度方式都带有优先级调度(Priority Schedule)轮转法(Round Robin)的痕迹。所谓轮转法,就是将各个线程轮流执行一小段时间。优先级调度则决定了线程按照什么顺序轮流执行。在具有优先级调度的系统中,线程都拥有各自的线程优先级(Thread Priority),具有高优先级的线程会更早的执行。

一般把频繁等待的线程称之为IO密集型线程(IO Bound Thread),而把很少等待的线程称为CPU密集型线程(CPU Bound Thread)。IO密集型线程总是比CPU密集型线程容易得到优先级的提升。

在优先级调度下,存在一种饿死(Starvation)的现象,一个线程被饿死,是说他的优先级较低,在它执行之前,总是有较高优先级的线程试图执行,因此这个低优先级线程始终无法执行。

线程的优先级改变一般有三种方式:

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

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

线程在用尽时间片之后会被强制剥夺继续执行的权利,而进入就绪状态,这个过程叫做抢占(Preemption),即之后执行别的线程抢占了当前线程。

Linux的多线程

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

系统调用 作用
fork 复制当前进程
exec 使用新的可执行映像覆盖当前的可执行映像
clone 创建子进程并从指定位置开始执行

fork函数产生一个和当前进程完全一样的新进程,并和当前函数一样从fork函数里返回,但不同的是本任务的fork函数将返回新任务pid,而新任务的fork将返回0。fork并不复制原任务的内存空间,而是和原任务一起共享一个写时复制(Cpoy on Write,COW)的内存空间。所谓写时复制,指的是两个任务可以同时自由地读取内存,但任意一个任务试图对内存进行修改时,内存都会复制一份提供给修改方单独使用,以免影响到其他的任务使用。

fork只能产生本任务的镜像,所以要使用exec配合才能够启动别的新任务。exec可以用新的可执行映像替换当前的可执行映像。

而如果要产生新线程,可以用clone。clone可以产生一个新的任务,从指定的位置开始执行,并且(可选的)共享当前进程的内存空间和文件等,如此就可以在实际效果上产生一个新线程。

线程安全

竞争与原子操作

如果多线程同时访问一个共享数据并进行操作(例如自增),就可能产生错误的值,因为这个操作被编译为汇编代码后不止一条指令,因此在执行的时候可能执行了一半就被调度系统打断,去执行别的代码。我们把单指令的操作称为原子的(Atomic),因为无论如何,单条指令的执行是不会被打断的。

但是面对复杂的操作,原子操作指令就力不从心了,这个时候我们就需要更加通用的手段:锁。

同步与锁

为了避免多个线程同时读写同一个数据而产生不可预料的后果,我们需要将各个线程对同一个数据的访问同步(Synchronization),就是说在一个线程访问数据未结束的时候,其他线程不得对同一个数据进行访问,相当于对数据的访问被原子化了。

同步的最常见方式是用锁(Lock)。锁是一种非强制机制,每一个线程在访问数据或资源之前首先试图获取(Acquire)锁,并在访问结束之后释放(Release)锁。在锁已经被占用的时候试图获取锁时,线程会等待,直到锁重新可用。

二元信号量(Binary Semaphore)是最简单的一种锁,它只有两种状态:占用与非占用。它适合只能被唯一一个线程独占访问的资源,当二元信号量处于非占用状态时,第一个试图获取该二元信号量的线程会获得该锁,并将二元信号量重置为占用状态,此后其他的所有试图获取该二元信号量的线程将会等待,直到该锁被释放。

对于运行多个线程并发访问的资源,多元信号量简称信号量(Semaphore),一个初始值为N的信号量允许N个线程并发访问。线程访问资源的时候首先获取信号量:

  • 将信号量的值减一
  • 如果信号量的值小于0,则进入等待状态,否则继续执行

访问完资源后,线程释放信号量:

  • 将信号量的值加一
  • 如果信号量的值小于1,唤醒一个等待中的线程。

互斥量(Mutex)和二元信号量类似,资源仅同时允许一个线程访问。但信号量可用被系统中的一个线程获取之后由另一个线程释放。而互斥量则要求哪个线程获取量互斥量,哪个线程就要负责释放这个锁。

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

读写锁(Read-Write Lock)有两种获取方式,共享的(Shared)独占的(Exclusive)

读写锁状态 以共享方式获取 以独占方式获取
自由 成功 成功
共享 成功 等待
独占 等待 等待

条件变量(Condition Variable)作为一种同步手段,作用类似于一个栅栏。线程可以等待条件变量,一个条件变量可以被多个线程等待;线程也可以唤醒条件变量,此时某个或所有等待此条件变量的线程都会被唤醒并继续支持。

可重入(Reentrant)与线程安全

一个函数被重入,表示这个函数没有执行完成,由于外部因素或内部调用,又一次进入该函数执行。一个函数要被重入,只有两种情况:

  • 多个线程同时执行这个函数
  • 函数自身(可能是经过多层调用之后)调用自身

一个函数被称为可重入的,表明该函数被重入之后不会产生任何不良后果,必须具有以下几个特点:

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

过度优化

编译器为了提高效率可能会把值放到寄存器中、交换毫不相干的两条相邻指令的执行顺序,CPU的动态调度为了提高效率也可能交换指令的顺序。

我们可以使用volatile关键字试图阻止过度优化,volatile可以:

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

而如果需要阻止CPU换序,通常操作是调用CPU提供的一条常常被称为barrier的指令,一条barrier指令会阻止CPU将该指令之前的指令交换到barrier之后,反之依然。

多线程内部情况

线程的并发执行是由多处理器或操作系统调度来实现的。用户实际使用的是存在于用户态的用户线程,并不一定在操作系统内核里对应同等数量的内核线程(注:这里的内核线程和Linux内核里的Kernel_thread并不是一回事)。

一对一模型

对于直接支持线程的系统,一对一模型始终是最简单的模型。一个用户使用的线程就唯一对应一个内核使用的线程(反过来不一定)。这样用户线程就有了和内核线程一样的优点,线程之间的并发是真正的并发,一个线程因为某原因阻塞时,其他线程执行不会受到影响。

缺点:

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

多对一模型

多对一模型将多个用户线程映射到一个内核线程上,线程之间的切换由用户态的代码来进行,因此相对于一对一模型,多对一模型的线程切换要快速许多,同时拥有近乎无限制的线程数量。

缺点:

  • 如果其中一个用户线程阻塞,那么所有的线程都将无法执行,因为此时内核里的线程也随之阻塞了
  • 在多处理器系统上,处理器的增多对多对一模型的线程性能不会有明显的帮助

多对多模型

多对多模型是将多个用户线程映射到少数但不只一个内核线程上

特点:

  • 一个用户线程阻塞并不会使得所有的用户线程阻塞,因为此时还有别的线程可以被调度来执行
  • 对用户线程的数量没什么限制
  • 在多处理器系统上,多对多模型的线程也能得到一定的性能提升,不过提升幅度不如一对一模型高
文章作者: 0bs3rver
文章链接: http://yoursite.com/2020/11/13/%E7%A8%8B%E5%BA%8F%E5%91%98%E7%9A%84%E8%87%AA%E6%88%91%E4%BF%AE%E5%85%BB%E8%AF%BB%E4%B9%A6%E7%AC%94%E8%AE%B0-%E7%AC%AC%E4%B8%80%E7%AB%A0%20%E6%B8%A9%E6%95%85%E8%80%8C%E7%9F%A5%E6%96%B0/
版权声明: 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自 0bs3rver的小屋