读本好书 《操作系统思考》

中间有事情折腾来回飘,其中也是抽了抽时间看了看书。


这里放上一幅偶遇的图,自己现在在哪里呢~

邓宁-克鲁格心理效应


身为互联网人,也难免不惊叹于互联网的发展,很久以前,一直在思考,明明有那么好的,先进的易用的科技产品,那么在平常百姓的家里怎么就这么难见到呢?一直在想,有一个怕平台,可以把这些东西推到人们身边。实则不然,哪里这么简单,更快,更方便,就是人们的选择吗?年龄,思维,甚至性格,都会去决定这些东西存在的可能。真正的拿了就走的自动售货机。。。

简介


一本入门级的讲操作系统的书,这里一样的是当作拾遗了,书写的还是很不错的,推荐!

每次都是感谢这些 开源书籍,以后有能里自己推一本!

这本书,从编译开始,到一个进程和进程的虚拟内存,文件系统,缓存,多任务,线程互斥等等的方面。

编译

这一部分从 解释性语言和编译性语言来引出了编译的这一个概念。

人们通常把编程语言描述为编译语言或者解释语言。前者的意思是程序被翻译成机器语言,之后由硬件执行;而后者的意思是程序被软件解释器读取并执行。例如,C被认为是编译语言,而Python被认为是解释语言。但是二者之间的界限并不总是那么明显。

所以,编译执行或解释执行并不是语言的内在特征。尽管如此,在编译语言和解释语言之间有一些普遍的差异。

很多语言都 可以被 编译 和解释两种执行方式。可以有解释性的C编译性的Python (Py2Exe)。JAVA 更加特殊,先进行编译为 Java字节码 ,dex。

在解释性语言中,在运行的过程中,变量的名称以及变量的值都会被保存在内存空间中的。

>>> globals()
{'__builtins__': <module '__builtin__' (built-in)>, '__name__': '__main__', '__doc__': None, 'a': 123, '__package__': None}
>>> locals()
{'__builtins__': <module '__builtin__' (built-in)>, '__name__': '__main__', '__doc__': None, 'a': 123, '__package__': None}
>>>

而在 编译性语言的运行过程中,是不会出现变量名称的,只会变量的值(内存地址)。

编译的过程:这个可以算是考点了,预处理,编译,汇编,链接。(.i -> .s -> .o -> .exe)。

在书中,这里细化为了 5 个步骤:预处理, 解析, 静态检查代码, 生成, 链接, 优化。

gcc hello.c -S  # 译得到 汇编代码
gcc hello.c -c  # 编得到 二进制中间文件
gcc hello.c -E  # 处理

进程

书中提到的很重要的两个概念,抽象虚拟化 。具体的概念就不展开了哈。OOP概念,虚拟化,可以理解为一种复杂的映射。

“虚拟”这个词通常用于虚拟机的语境中,它是一种软件,可以创建运行特定系统的专用计算机的幻象。实际上,虚拟机可能和其它虚拟机一起运行在不同的操作系统上。

在虚拟化的语境中,我们通常把真实发生的事情叫做“物理的”,而把虚拟上发生的事情叫做“逻辑的”或者“抽象的”。

在操作系统上讲,其对进程来讲是 抽象的,内存来讲是虚拟化的


进程隔离,进程的内存空间当然是独立的,且受保护的,两个进程的数据,如果在一起了,那么相互影响,结果是双双崩溃。(当然,通过注入技术,是可以实现对其他进程的内存访问。)

操作系统最重要的目标之一,就是将每个进程和其它进程隔离,使程序员不必考虑每个可能的交互情况。提供这种隔离的软件对象叫做进程(Process)。

进程,可以称之为隔离的软件对象。对象保护以下的数据对象:

  • 代码段
  • 相关数据:静态区,堆区,栈区。
  • 任何等待状态的IO资源
  • 程序的硬件状态

当进程出现了 Fork :这种情况下,各个进程共享程序文本,但是拥有不同的数据和硬件状态。


进程隔离的实现:

  • 多任务 可以在程序的任何时候中断它,保存寄存器状态。
  • 虚拟内存 实际上,进程的内存在物理内存里面是分片映射的,区域不会重复-
  • 设备抽象 可用的IO设备被抽象成对象,系统自动的实现 资源的分配及调度

“TTY”代表“电传打字机”(Teletypewriter),它是原始的机械终端。

虚拟内存

你一定听过人们谈论32位和64位系统。这些术语表明了寄存器的尺寸,也通常是虚拟地址的大小。在32位系统上,虚拟地址是32位的,也就是说虚拟地址空间为从0到0xffff ffff。这一地址空间的大小是2 ** 32个字节,或者4GiB。

在64位系统上,虚拟地址空间大小为2 ** 64个字节,或者4 * 1024 ** 6个字节。这是16个EiB,大约比当前的物理内存大十亿倍。虚拟内存比物理内存大很多,这看上去有些奇怪,但是我们很快就就会看到它如何工作。

这里写到了,进程的虚拟内存结构,运行中的进程数据组织为 4 个段:

数据段 功能 位置
text 程序文本,代码段 段靠近内存“底部”,即接近0x00000000的地址。
static 全局变量,和使用static声明的局部变量。 段通常刚好在text段上面。
stack 如其名是栈空间,里面是栈帧(每个Function运行时分配(参数,局部变量),重要的是RP,返回地址) 段靠近内存顶部,即接近虚拟地址空间的最大地址0xffffffff。在扩张过程中,它向低地址的方向增长。
heap 堆区,我们动态分配,以及释放的内存段 malloc 内存泄露的重灾区 通常在static段的上面。在扩张过程中,它向高地址的方向增长

在虚拟内存地址的分布,也是有着固定的关系。


地址翻译 虚拟地址(Virtual Memory) 翻译成 物理地址 (Physical Memory)。这里就有了很重点的地方,就是内存映射。MMU(内存管理单元),就实现了这样的一个很重要的角色,位于CPU和主存之间。MMU在VA和PA之间执行快速的翻译。

  1. 当程序读写变量时,CPU会得到VA。
  2. MMU将VA分成两部分,称为页码和偏移。“页”是一个内存块,页的大小取决于操作系统和硬件,通常为1~4KiB。
  3. MMU在“页表”里查找页码,然后获取相应的物理页码。之后它将物理页码和偏移组合得到PA。
  4. PA传递给主存,用于读写指定地址。

文件与文件系统

HDD/SSD

机械硬盘比较复杂。数据存储在块内,它们布局在扇区中,扇区又组成磁道。磁道在盘片上以同心圆的形式排列。

固态硬盘稍微简单一些,因为块按顺序被标号。但是这会产生另一种困难,每个块在变得不可靠之前,只能被读写有限的次数。

在抽象的层面讲,文件系统就是文件名到文件内容的键值映射,如果你认为名称是键,内容是值,文件系统就是一种键值对的数据库

FILE *fp = fopen("/home/downey/file.txt", "w");
fputc('b', fp);     // 被缓冲的
char c = fgetc(fp);
fclose(fp);         // 此时回写硬盘

这里的文件打开后,得到了一个文件指针。


从物理层面。来看待磁盘的读性操作是相当的费时的,从磁盘上的进行一个块的读 需要 2~6ms

所以在操作系统的层面上有以下的处理:

  • 块操作 以磁盘的块为单位加载到内存中进行处理,即使单字节数据亦然
  • 预取 在对文件首块进行访问的时候,后面的部分已经开始了预取
  • 缓冲 对文件进行的写操作,会在内存中线进行缓冲,之后统一的进行写入,提高写入性能

数据在磁盘上如果是连续排列的,那么读写性能将会很客观,不过事实上很难做到。数据频繁的读写,很难给每一个文件都找到合适的连续空间。所以多数操作系统 把文件分散在不同的地方,使用数据结构来进行数据库的跟踪

在 Unix 的文件系统中,数据结构被称之为inode(index node)。

文件内容就是数据,所以关于文件内容的数据就是数据的数据,所以为“元数据”。

inode 这个数据结构,包括文件的拥有者,权限,时间戳,等待,重要的是文件内容,这里使用间接存储,使用多级的结构,把文件分散的存储在磁盘的不同地方。第一大文件,使用多级映射,(三级)

FAT 是一种很常见的文件结构了,他的全名是 文件分配表(File Allocation Table),其思路是把磁盘的每个块都会有一个条目,这个条目的上下文叫做簇。

目录包含了每个文件的第一个簇的指针,而每个条目的指针又指向了下一个簇,所以这里形成了一个类似于链表的结构。

所以这里问题来了,在inode 的方法的文件系统中 (ext3,ext4…)好处是不会出现文件的碎片化。但是在 fat 的文件系统下,就会出现文件的碎片化 ,也就是每个簇在物理地址上的差别太大,导致多次的寻道,导致了IO性能的问题。

内存管理

这里指的是动态内存分配,malloccollocfreerealloc。下面的是常见的内存错误

  • 使用未分配内存
  • 释放后访问
  • 释放未分配内存
  • 重复释放内存
  • 非法使用 realloc

内存错误,通常很严重很各色诡异,所以也是最难解决的问题。

  • 如果存在未分配的指针,运气好,地址落在了代码段,这里的内存是只读的,进行数据写之后,通常会有段错误发生,所以可以看到异常的发生。
  • 未分配指针,如果地址落在了可读性的内存区域内,导致的问题可能更加严重,程序本身并没有明显多万,带式可能一些数据被非法的修改,导致了一些诡异的情况。而且十分的不易察觉。

当我们使用函数进行了内存的分配,但是我没有使用 free ,到后面我们又搞丢了这个分配空间的地址,那么这就导致了内存泄漏。 进程占用了过多的无用内存。如果,进程很快退出,可能不会有影响。但是时间久了,就会越来越多,到后面可能直接导致了Core 的发生。

不过,内存本身有管理机制,这种泄露使用的内存页,一般是不会再次被访问了,所以一般会被置换到硬盘的交换区。对系统性能的影响不是很大。


缓存

操作系统在缓存的等级来讲,已经实现了无感知,硬件本身帮我们实现了缓存的存在

https://wizardforcel.gitbooks.io/think-os/content/ch7.html

内存换页:

  • 大多数进程不会用完所分配的内存。text段的许多部分都永远不会执行,或者执行一次就再也不用了。这些页面可以被换出而不会引发任何问题。
  • 如果程序泄露了内存,它可能会丢掉所分配的空间,并且永远不会使用它了。通过将这些页面换出,操作系统可以有效填补泄露。
  • 在多数系统中,有些进程像守护进程那样,多数时间下都是闲置的,只在特定场合被“唤醒”来响应事件。当它们闲置时,这些进程可以被换出。
  • 另外,可能有许多进程运行同一个程序。这些进程可以共享相同的text段,避免在物理内存中保留多个副本。

多任务

操作系统中,实现多任务的这部分叫做“内核”。在坚果或者种子中,内核是最内层的部分,由外壳所包围。在操作系统各种,内核是软件的最底层,由一些其它层包围,包括称为“Shell”的界面。计算机科学家喜欢引喻。

在操作系统中,由于处理核心数的限制,进程之间,事实上都是串行的,他们之间由内核,进行快速的上下文切换,我没感受不到间隔,所以认为是并行的,这也就是我们的多任务。

内核在不断的处理系统的中断使用终端服务代码段。下面是相应中断的过程:

  1. 当中断发生时,硬件将程序计数器保存到一个特殊的寄存器中,并且跳到合适的中断处理器。
  2. 中断处理器将程序计数器和位寄存器,以及任何打算使用的数据寄存器的内容储存到内存中。
  3. 中断处理器运行处理中断所需的代码。
  4. 之后它复原所保存寄存器的内容。最后,复原被中断进程的程序计数器,这会跳回到被中断的进程。

在发生了中断之后,系统并不会总是恢复被中断的任务,而是可能从内存中取出另一个进程的状态进行恢复,这就实现了上下文切换

在多任务的系统中,每个进程都允许运行一小段时间,叫做“时间片”或“quantum”。在上下文切换的过程中,内核会设置一些硬件计数器,它们会在时间片的末尾产生中断。当中断发生时,内核可以切换到另一个进程,或者允许被中断的进程继续执行。操作系统中做决策的这一部分叫做“调度器”。

这里就是,进程调度了,这里就是时间片轮转法。让我想到之前 玩 手机 OC的时代:

CPU处理器和IO调度详解—让手机更省电更流畅


进程状态 :由三态,五态模型。

一台计算机上可能运行着成百上千条进程,但是通常大多数进程都是阻塞的。大多数情况下,只有一小部分进程是就绪或者运行的。当中断发生时,调度器会决定那个进程应启动或恢复


关于进程调度:

  • 进程可能被不同的资源限制。执行大量计算的进程是计算密集的,也就是说它的运行时间取决于得到了多少CPU时间。从网络或磁盘读取数据的进程是IO密集的,也就是说如果数据输入和输出更快的话,它就会更快,但是在更多CPU时间下它不会运行得更快。最后,与用户交互的程序,在大多数时间里可能都是阻塞的,用于等待用户的动作。操作系统有时可以将进程基于它们过去的行为分类,并做出相应的调度。例如,当一个交互型进程不再被阻塞,应该马上运行,因为用户可能正在等待回应。另一方面,已经运行了很长时间的CPU密集的进程可能就不是时间敏感的。
  • 如果一个进程可能会运行较短的时间,之后发出了阻塞的请求,它可能应该立即运行,出于两个原因:(1)如果请求需要一些时间来完成,我们应该尽快启动它,(2)长时间运行的进程应该等待短时间的进程,而不是反过来。作为类比,假设你在做苹果馅饼。面包皮需要5分钟来准备,但是之后需要半个小时的冷却。而馅料需要20分钟来准备。如果你首先准备面包皮,你可以在其冷却时准备馅料,并且可以在35分钟之内做完。如果你先准备馅料,就会花费55分钟。

这里引用文中的话,如何更明智的选择调度方式,的第一点提到了计算密集型,以及 IO密集型,这里的密集型可以理解为时间主要花费在。

线程

进程下面派生出了线程,和进程不同,他们共享:代码段,静态区,以及堆区。栈是独立的。

所以在使用线程的时候,最大的问题,就是线程的同步问题,如何做到线程不会同时的区争夺一个资源,或者同时区读写一个变量。死锁和竞态

进程是资源分配的最小单位,线程是调度的最小单位。

在 POSIX 的标准中,一个安全的新建线程的示例代码如下,记得编译的时候静态链接上 pthread

pthread_t make_thread(void *(*entry)(void *), Shared *shared)
{
  int n;
  pthread_t thread;

  n = pthread_create(&thread, NULL, entry, (void *)shared);
  if (n != 0) {
    perror("pthread_create failed");
    exit(-1);
  }
  return thread;
}

可以,比较容易的发现,一个输出型参数,后面的就是函数的入库,再往后就是我没传入的线程函数参数。


有提到,线程直接是共享代码段,静态区,以及堆区。所以,当一个线程对变量进行修改的时候,会影响到其他的线程中的值,这对于这个变量的操作是 非原子的。所以这里为了实现线程间的同步 涉及到了 互斥体(mutex)

typedef struct {
  int counter;
} Shared;

typedef struct {
  int counter;
  Mutex *mutex;
} Shared;

对传入的参数进行修改,这里使用了mutex

一样使用 POSIX 初始化互斥体:

Mutex *make_mutex()
{
  Mutex *mutex = check_malloc(sizeof(Mutex));
  int n = pthread_mutex_init(mutex, NULL);
  if (n != 0) perror_exit("make_lock failed");
  return mutex;
}

这样在进程的函数中,先得到互斥体,如果得不到,说明有线程正则使用,那么被阻塞。直到另一个线程完成操作,解锁互斥体之后,该线程才得以运行。


书中还在后面细化了这部分,可以看看代码,便跳过了。

互斥体,信号量

互斥体前面已经有提到了,先申请锁,如果得不到,说明有使用,就被阻塞,等待释放锁,得到之后立即加锁,其他的线程无法得到锁,完成之后释放,就是这样的过程,实现了线程间的同步。


信号量实际上是一种高阶的互斥体,互斥体可以理解为二值信号量。信号量里面涉及到了 PV 操作。

通过互斥体来讲:mutex 为 1 说明资源存在,为0 说明正在被占用

到了信号量,sem 为 10 说明有十个资源, 为0 说明已经完全分配, -1 说明有一个进程在等待资源。

发表回复

您的电子邮箱地址不会被公开。 必填项已用 * 标注