什么是操作系统?

什么是操作系统? 为什么说C / C++ 更底层 ? 电脑里只有一个CPU, 多线程是怎么实现的 ?

一些简单口胡, 也算对本学期的学习做一个总结

一言蔽之, 操作系统是管理下层硬件, 为上层软件提供统一的, 容易理解的抽象API的 软件 .

硬件是什么样的 ? - 冯诺依曼结构

CPU

CPU是一台计算机的大脑. 负责运算和管理整个计算机. 你可以简单把它看成能接收CPU指令, 做出相应动作的电路板.我们会讨论的典型的指令如下.(顺便, 如果你想知道你写的代码是怎么变成CPU指令的, 你需要知道的是编译原理)

#运算
ADD 1, 2
#访问内存, 把内存地址addr处的数据放入寄存器
Mov addr, %eax

这里写的其实是汇编语言, 它和CPU指令其实是一回事. 汇编语言是给人看的, 所以写成英文记号, CPU指令是电路看的, 所以实际上是一串二进制串, 或者更直接一点, 是一串高低电平. 一条汇编语言和一条CPU指令是一一对应的, 我们接下来不会区分这两个名词.

存储结构

关于存储结构, 我们需要记住的是: 有多个级别的存储, 越快的越贵, 因而越快的容量越小(当然, 如果你有钱, 那你就可以为所欲为地使用最快的存储设备).为什么我们会在乎存储结构读取的快慢呢? 因为CPU的时间是非常宝贵的, 我们不希望每次CPU访问内存的时候都浪费很多时间等待存储设备. 就像你点外卖不喜欢等待一样, 操作系统工程师也不喜欢CPU等待.


CPU寄存器

图中Register, 读写速度可以认为和CPU运算一样快, 读取Register内的数据几乎可以认为不花费时间.断电失去数据.

Cache

这是比较复杂的一部分, 我们待会儿返回来讲

内存

图中Main memory部分. 就是我们熟知的电脑内存啦. 现在你的笔记本的内存一般是 4-16GB. 但是访问内存也比访问寄存器耗时得多, 你可以在图中看见大概是十倍时间.断电失去数据.

硬盘

图中Magnetic disk部分, 最近几年火起来的SSD就是一种硬盘. 就像你可能知道的一样, SSD, 或者叫做固态硬盘, 比传统的机械硬盘快很多, 但是读写速度依然比内存慢得多. 从图中你可以看到, 大概慢了三个数量级

磁带

老掉牙的东西啦. 一般只用很老的老项目使用了.我们这次不讨论它.

输入输出设备

包括计算机和人交互的一切接口. 键盘, 鼠标, 屏幕, 网卡等等.

好了, 关于计算机硬件, 我们就说这么多了. 如果你有一点晕, 没关系! 你只需要大概记住这两条就可以了:

  • 只有CPU会计算
  • 存储设备是多级的. 从寄存器到内存到硬盘, 速度越来越慢, 容量越来越大

为什么你需要操作系统?

假设你是一个程序员, 你写了一个C程序

int main() {
    int a = 1;
    int b = 2; 
    int c = a + b;
    printf(%d, c);
    return 0;
}

你的程序经过编译大概变成了这样的CPU指令

# 初始化a和b , mov指令把前一个操作数的值赋值给后一个操作数
Mov 1, a
Mov 2, b

# %开头的是CPU寄存器, 你可能还记得它是最快最小的存储设备, 我们要把a和b从内存里拿出来, 放进寄存器里
# 接下来Add指令才能工作 
Mov a, %eax
Mov b, %ecx

# 加两个寄存器的值, 和写入后一个寄存器中, 在这里是写入eax寄存器
Add %ecx, %eax

# 把这个值写回内存
Mov %eax, c

#打印, 即把内存里的值送到显示器,这里涉及一系列系统调用,现在先不管

指令送进内存, CPU从内存里读取指令(读取指令和执行指令是CPU硬件实现的), 执行.访问内存, 执行计算. 一起看起来都很美好. 看起来我们不需要一个操作系统.

没错! 恭喜你发现一个惊天大秘密! 我们就是不需要操作系统!

如果我们的程序就一直这么简单的运行, 那可能的却不需要. 可这不符合我们对现代计算机的认知. 浏览器和音乐播放器是两个不同的程序,为什么你能一边刷网页一边听歌? 一定是这两个程序一起运行了, 可是按说你只有一个CPU, 一套内存, 只能跑一个程序, 为什么你能同时运行好几个程序呢?

是的, 这就是操作系统最基本的功能, 操作系统负责管理CPU和内存, 让每一个程序都觉得自己是独立运行的.

操作系统怎么做到的? 虚拟化

虚拟化这个词可能和大数据和云计算一起在你的耳边回荡已久. 其实它就是欺骗和造假的另一个说法. 还记得我们说过, "要让每一个程序认为自己是独立运行的"吗? 我们怎么办呢, 我们假造一系列运行需要的硬件, 让每个程序都觉得自己占有了硬件, 正在独立运行. 而且让它们的执行互不干扰

进程

进程是欺骗的开端. 一个进程就是可以让一个程序感觉自己正在独立运行的所有环境. 想一想, 一个程序运行大致需要做什么?

  1. CPU要执行程序指定的CPU指令
  2. 执行的过程中必然要访问内存
  3. 执行的过程可能要与人交互

所以一个进程也围绕着虚拟出一个CPU, 一份内存展开.

CPU虚拟-分时复用

怎么虚拟出一个CPU呢? 好办, CPU运行速度比人能察觉的快得多. 它可以在两个进程中间切换, 这一个瞬间给你显示网页, 下一个瞬间给你播放音乐. 对,事实就是这样的, 你的CPU在负责给你唱歌的时候,只是在数十个任务中时不时唱一句, 但是你会觉得音乐是连续的.

虚拟内存

这是比较复杂而且比较细节的一节. 我希望我能讲明白为什么要虚拟内存, 解决思路大概是什么样的, 现在的方案做到了什么事情.至于实现细节其实不那么重要. 我建议阅读的时候考虑先跳过 "怎么实现虚拟内存管理" 一节.

为什么要虚拟内存?

你刚刚可能就开始困惑, CPU只有一个, 在确定时间只能执行一条CPU指令, 的确可以说是只能执行一个程序, 可是我有16GB大内存, 为什么不能同时执行多个程序?

是这样的, 我们重新看这两句CPU指令

Mov a, %eax
Mov b, %ebx

我们刚刚说这两句CPU指令把a和b分别读入寄存器. 这是不准确的. 内存是不知道谁是a谁是b的, 内存只能由一个确定的地址, 访问内存里特定的一块地方. 存储器是按地址访问、线性编址的空间,像一条数轴

我们假设自己拥有一个4GB的内存. 每Byte是一个最小读取单位. 一个内存地址由32bit 二进制数描述(你会发现刚好能寻址4GB) , 刚刚两句CPU指令可能会是这样

#0x是表达16进制数的一个习惯, 不用在乎细节, 我这里只是想表达它是一个确定的地址
Mov 0x00,%eax 
Mov 0x04,%ecx

在这两条CPU指令运行之前, a和b就被放在那个地址了.(这个事情也是操作系统保证的, 我们晚点谈)

绕了这么一大圈, 问题终于出现了, 程序员写代码的时候能考虑自己的程序运行的时候是不是和别人一起运行吗? 或者说, 程序员能够一开始就知道自己的数据被放到什么地方, 从而更改自己的代码吗?

不行. 每一个程序员写代码的时候, 总是假设只有自己的程序在运行,(如果不这样, 编程会变得异常困难) 对应到内存上, 每一个程序员总是假设计算机的4G内存全部属于他.所以他会从0x00处开始使用内存.

这段对你来说可能有一点难理解. 因为你编程的时候好像从来没有考虑过内存的事. 在大部分高级语言实现里, 程序员是不能直接看见内存的. 这是同为高级语言, 我们说C更加底层的原因之一. 在这里, 你需要记住的是编译器也不能做到这样的事情.

说到这里, 你可能有点明白问题在哪里了, 问题在于数据放在内存的什么位置, 在程序运行之前就写定了.

好了, 假设我们有一个程序A, A里的数据被放在0x00地址, 又有一个程序B, B里的数据也被放在0x00位置. 他们要同时运行, 怎么办? 内存的空间放得下两个程序的数据, 但是我们通过访问0x00的时候, A程序和B程序都希望访问到自己的数据, 如果他们一起运行, 内存0x00处到底应该放什么呢?

所谓虚拟内存, 就是给每个进程一个它自己的映射关系F, 使 $$ 物理内存地址 = F (虚拟内存地址) $$ A和B都在访问自己的0x00, 但是由于他们的映射关系F不同, 这两个访问会访问不同的物理地址(内存的真正的地址).为什么要叫虚拟化呢? 因为程序A和B(或者说开发A和B的程序员) 是看不到这个映射关系的. (从时间关系上也没法看到, 这个映射关系到程序运行的时候才真正确定下来) , 他们就是认为整个机器的4GB内存都属于他, 他访问内存使用的全是虚拟内存地址.

操作系统分配和管理这个映射关系

你可能会好奇, 那每一个进程需要的数据到进程运行前一刻才导入内存行不行呢? 运行进程A就把进程A需要的数据全部导入内存, 到需要切换到进程B的时候才把B的数据导入内存行不行呢? 不行, 因为CPU比内存快得多, 这种方式会导致每次CPU切换任务的时候浪费大量的时间把数据填入内存, 那CPU运行多快都没用了.所有的时间都浪费在等内存了.

怎么实现虚拟内存管理

我们绕了好大的圈子, 终于说明白了问题在哪. 我们再描述一下我们的问题:

  1. 我们得给所有每一个的进程一个虚拟的内存映射, 让它认为从0x00开始所有的内存都属于自己, 每一个进程都可以使用从0到4GB的内存空间.
  2. 分配和管理这个映射关系, 让每一个进程都能访问到他想要的数据 (这并不简单 , 举例说一个一个进程认为自己把数据a保存在0x04处, 操作系统得做两件事情来保证他能访存到)
  3. 把数据a保存在真实的内存的某个地方, 假设是0x08.
  4. 给进程映射管理里添加一条F(0x04 ) = 0x08
  5. 保证每个进程的访存不相互干扰. 更具体一点, 保证进程A不能访问到进程B在内存中的数据(不然网页云音乐就能监视你所有的应用使用情况了)
接下来我要谈论具体的方案了, 这部分细节多, 很难避免讲的又臭又长, 我建议你可以先跳过去, 或者先看看图了解个大概, 总之我对这一节能不能写好最没有把握

在谈论具体的方案之前, 你需要先记住这几个概念: (太不公平了! 我还什么都没解决就要记住这么多概念)

  • 32位系统和64位系统: 内存是按Byte访问的(即访问内存0读出的是第0byte,即第0bit到第7bit).所谓32位系统就是指, 内存编址是由一个32bit的数字确定的. 你会发现32bit能描述的最大地址空间就是4GB, 我们基本是基于32位系统做讨论.
  • 内存虚拟地址, 每个独立运行的进程看见的内存的地址, 也是编写应用程序的程序员"看见的"地址.
  • 内存物理地址, 数据被实际上保存的地址, CPU访存的时候, 内存接到的真正的访存地址( 如果你觉得他们没有区别, 直接停止阅读联系我, 我上面一定没写好)
  • 现代解决方案一共有两套, 两套解决方案都是需要特殊硬件协助的. 虽然我们强调操作系统是软件, 但是它会需要和一些特殊的, 应用程序员看不见的硬件打交道. 关于内存管理, 这些特殊硬件都是CPU生产商做在CPU里的, 是CPU提供的功能.

现代解决方案有两套, 分别叫做段式管理页式管理.你会发现其实他们挺相似. Windows采取的是段式管理 + 页式管理.Linux采取的是纯页式管理.

段式管理

​ 段式管理示意图


段式管理是比较容易想到的方案. 在这个方案基于一个简单的映射关系F $$ F : 物理地址 = 虚拟地址 + 基地址偏移 $$ 每个进程访问的虚拟地址只要加上基地址偏移就能得到数据在内存中物理地址

为了高效实现, 我们需要借助两个特殊硬件的帮助

  • 段基址寄存器(你还记得寄存器是什么吗? 是CPU身边的存储介质, CPU访问它的时间可以忽略不记, 就像你记在大脑里的知识一样), 寄存器里保存一个进程的基地址偏移量, 每次CPU执行到访问内存的CPU指令的时候, CPU自动加上基地址偏移, 这样就实现了虚拟地址到物理地址的转换
  • 段长度寄存器: 这个寄存器是保证进程独立运行的, 每个进程都要申请好自己要使用的内存最大值, 保存在这个寄存器里, 接下来如果CPU在执行一条访问内存的CPU指令的时候发现该指令在访问的地址超过最大值, CPU拒绝执行这条指令. 比如说如果我们作为进程A尝试访问一个很大的数字, 这个数字已经超过A申请的运行内存(黄色部分), 那么这次访问内存就会被拒绝.

整个访问内存的流程就是这样


MMU: Memory management unit, CPU的一部分, 负责和内存管理相关的硬件, 我们刚刚说的检查访问是否合法, 添加基地址偏移这些过程都是它去做的.

逻辑地址: 虚拟地址

操作系统的工作就是了解用户进程申请多少内存, 分配内存, 维护段表(段表里维护着所有最近可能运行的进程的基地址偏移和最大访存长度), 总之就是保证每一个进程都能找到自己的物理内存的一切杂活.

页式管理

你会意识到, 段式管理是建立在运行前申请和分配内存机制之上的.如果你想在进程运行的过程中申请内存, 段式管理就会变得很困难. 可是C/C++确实支持动态分配(C中的malloc, C++中的new), 这部分内存是在运行中才知道需要分配的.( C/C++的静态数据动态分配的区别就在这里, 普通数组长度必须是一个常数, 就是因为这部分长度必须在编译的时候就确定下来.从而在运行前申请好. 而动态分配的数组长度可以是一个表达式, 即可以在运行到那一句代码的时候才决定申请内存空间大小).所以我们需要一个可以方便地在运行的时候分配内存空间的方案.

在讨论页式管理的虚拟地址->物理地址映射关系之前, 我们先想一想, 最灵活的映射关系是什么样的? 不借助任何公式, 简单的记录每一条映射记录 应该是最灵活的. 用python说, 就是我们有一个dict 记录每一个虚拟地址应该映射到某个物理地址.

dict_virtualaddr2pysicaladdr = {
    virtualaddr0 : pysicaladdr0,
    virtualaddr1 : pysicaladdr1,
    virtualaddr2 : pysicaladdr2,
}

但是真的这么存的话, 需要太多的空间存储映射关系了. 那么我们怎么办呢? 我们把每4KB的内存空间划分为一个页, 从内存0x00处开始给页编号. 即0x00000000--0x00000FFF是第0页, 0x00001000-0x00001FFF是第1页. 你可能已经发现了, 一个32bit的地址, 截前20bit就是页号.

那么我们就可以稍微更改一下我们的dict了

dict_virtualpage2pysicalpage = {
    virtualpage0 : pysicalpage0,
    virtualpage1 : pysicalpage1,
    virtualpage2 : pysicalpage2,
}

进程访问一个32bit的虚拟内存地址的时候, 先用前20bit找到他的物理页, 再用后12bit作为在页内的偏移量访问内存. ( 这个过程实际上是这样, MMU负责从虚拟地址的前20bit找到物理页, 这个物理页也能被20bit长的数字描述, 然后把后12bit连接到得到的物理页后面, 就得到了要访问的物理地址)

32bit虚拟内存地址 = 20bit虚拟页号 \space 拼接 \space 12bit虚拟页内偏移

物理页号 = F(虚拟页号)

32bit物理内存地址 = 20bit物理页号 \space 拼接 \space 12bit虚拟页内偏移

你会发现页内偏移是不变的.

操作系统为每一个进程维护它的dict. 确保一切正常运行.

页式管理如何满足动态分配呢? 操作系统维护着一个链表, 表上的是还空闲的物理页(每一个节点代表一个物理页), 每一次进程申请内存(无论是运行前还是运行中) , 操作系统计算进程需要几个页, 从空闲链表上取下相应数目的物理页, 把映射关系保存到对应进程里)

​ 页式管理内存分布示意图, 进程A使用了3个页, 进程B使用了2个页.


在页式管理中, 我们需要的特殊硬件叫做cr3寄存器.

我们刚刚保存的dict_virtualpage2pysicalpage, 这个东西叫做页表, MMU(如果你突然记不得它是什么, 记住它是CPU里的一个硬件, 就负责处理内存访问)需要知道正在运行中的进程的页表在哪里, 没错, 就是存在cr3寄存器里.操作系统负责在每一次进程切换的时候更新cr3寄存器.

我们再来想一想, 页式管理能保证进程独立运行吗? 具体一点说,它能保证进程A访问不到进程B的数据吗? 只要操作系统维护得当, 这是很容易做到的, 只要保证A进程的页表使用的物理页面和B进程使用的物理页面不同就行. 你会意识到这涉及到空闲页面的分配和回收.

真实的情况比这个稍微复杂一点, 具体而言, 在32位系统中, 页表是两级的, 0-10位在一级页表中寻找二级页表入口, 11-20位寻址到物理页面. 如果有人和你谈什么两级页表什么B+树页表, 那大概就是在说这个事情, 也很好理解, 大概就是图这个样子

​ 页式管理访存方式


​ 真实情况: 二级页表访存


你会意识到, 页式管理机制把一次简单的访问内存变成了三次. (访问一级页表一次, 访问二级页表一次, 访问数据一次) 这使得性能大幅下降, 等一次内存访问我们都很难忍受, 等三次是非常过分的事情, 这个我们会有东西去解决, 就是我们谈冯诺依曼结构的时候跳过没谈的cache.

好了, 到现在, 我们谈论的虚拟化可以告一段落了. 还记得我们一开始为什么要谈论虚拟化吗? 我们想让每一个应用都觉得它在占有硬件, 独立的在运行, 为此我们创造了进程的概念, 进程是一个应用程序运行需要的环境, 我们为一个进程分时复用了CPU, 虚拟出了只属于它的内存映射, 这样我们的程序就能独立地跑起来了.

进程和线程

我们已经讨论过什么是进程了. 一个进程是一个应用程序独立运行需要的虚拟环境, 比较术语的说法是: 进程是资源分配的单位. (什么是资源呢? CPU时间, 内存空间, 一个程序运行需要的所有硬件都叫做资源, 你会发现操作系统就是做资源管理的) 在一段时间内, 可能有多个应用程序要求运行, 这个时候, 操作系统决定什么时候谁应该运行. 这个过程叫做进程调度.

进程调度

你已经发现操作系统能够营造"多个进程共同运行"的假象了.我们再理一理这个假象是怎么制造出来的.

进程切换

我们只有一套硬件, 只能有一个进程运行在其上. 操作系统决定哪个进程现在得到运行的权利. 假设我们同时提交了三个进程A,B,C要运行. 这三个进程进入操作系统的就绪队列, 操作系统可以从中选一个开始运行(依据调度算法),假设我们现在选取A运行 .在A进程运行的过程中, 操作系统可以切入, 停下A(你可能想象, 这需要把A的当前寄存器保存在内存某处, 以便等等A继续运行) , 选取B, 让B运行(如果你还记得上一节虚拟内存的内容, 这里需要将B的页表入填入CPU的cr3寄存器中华) . 这个过程叫做进程的切换.

进程状态

进程状态主要是表示现在进程是否在运行的, 理论上可能有这么几种状态:

  • 运行 进程占据硬件, 正在运行
  • 就绪 进程处在随时可以运行的状态, 等待操作系统选中
  • 阻塞 进程因为等待某个事件, 到条件成熟之前都不能继续运行, 比如进程等待硬盘读取, 或者进程等待网络消息
  • 挂起 之前三个状态的进程, 他们的数据和代码都是在内存中的, 有时候内存不够用, 我们可以把某个进程的代码和数据写入硬盘, 从而清空它占据的那部分内存, 这样的进程状态叫做挂起

这只是理论上的进程状态,实际上依据操作系统的不同, 实现的进程状态会有所区别。

为什么需要调度?

操作系统是一个大而且复杂的软件, 软件中没有功能是先于需求产生的, 当你看到操作系统的某个特性的时候, 可以先想一想, 没有它行不行? 这样可能有助于你了解功能. 那么没有调度行不行呢?

假如操作系统不调度, 进程A, B, C就依据提交的顺序一一运行, A运行完, B开始运行, 这样行不行呢? 行。 正确性是毋庸置疑的。但是效率会有一些问题, 假如A在运行中读了硬盘, CPU就有一直等到磁盘数据返回, 才能继续工作, CPU的时间是非常宝贵的, 我们不希望等待, 所以我们做了一个机制, A读硬盘的时候,操作系统介入, 把A的状态改为阻塞, 在就绪的进程(B和C) 中选择一个运行(假设是B)。 直到A读取的硬盘返回数据, 操作系统再把A的状态改成就绪, 等待下一次选到它运行。 这样, CPU就可以去跑B的程序, 而不至于等待。 你会意识到, 有了调度, A, B, C都运行完需要的时间减少了。

进程怎么调度有很多标准(比如是尽量公平的让每个进程运行相同的时间, 还是某个进程有优先级应该运行更长的时间), 依据不同的标准又有很多的调度算法. 通常跑在你我计算机上的调度机制叫做时间片轮转.时间片轮转基于这么一个统计规律: 一个进程(一个计算任务) 中, 只有前2-8 毫秒是计算密集的, 换言之只有这段时间是需要CPU的, 接下来进程就很可能要读硬盘, 或者等待网络了。 基于此, 我们设定一个时间片(一般是5毫秒),每间隔一个时间片, 操作系统打断一次运行的进程, 进行一次进程切换。

你会意识到, 时间片轮转只设定了每个进程每次能运行多长时间(一个时间片), 并没有解决如何在就绪进程队列中选一个开始运行的问题。 解决这个问题的东西叫做进程调度算法 。我们不打算在这里讨论进程调度算法,其实很多情况下简单的先来先服务就能工作得不错。

线程

什么是线程呢? 你作为程序员可能已经接触了一些多线程编程的概念。 比如服务器为每一个TCP连接创建一个线程,他们总是告诉你多线程比单线程快, 但也有所谓“线程安全问题”, 那么什么是线程呢?

我们回到进程切换, 从进程A切换到进程B的时候, 需要更改CPU的cr3寄存器, 从而更改虚拟内存映射,假设两个进程共享一套虚拟内存, 这部分的开销是不是就可以省去? (实际上省去的开销不只是更改一个寄存器这么简答, cache会清空,而这个硬件是保证我们高速访问内存的关键, 我们往后会谈论一下cache)

答案是肯定的。 那么下一个问题就是, 什么样的计算任务是需要在同一套内存映射下做不同计算的? 或者说的更贴近程序员一点, 什么样的计算任务是需要在同一套数据下跑不同代码的? 你会发现这种情况还蛮多。 比如说游戏就是这样, 所有的人物都在一个环境下, 每一个人物的行为需要单独计算。

好了, 你可能意识到什么是线程了。 线程就是共享虚拟内存映射的进程, 实际上, 多个线程从属于一个进程, 这些线程都共享进程的虚拟内存映射。 但是每个线程在计算调度上是独立的。 举个例子, 一个游戏进程拥有ABCD四个线程, 代表4个人物, 计算人物行为这个任务就变成了在操作系统ABCD四个线程中间调度(线程调度和我们刚刚谈论的进程调度几乎完全一致, 只是更节省时间)。实际上, 线程才是调度的单位。 操作系统在做调度的时候, 考虑的是某个线程是不是应该开始运行了。 (你会发现这打破了你刚刚进程那里理解到的知识。 事情就是这样, 操作系统是一个不断生长的东西, 很多东西都是缝缝补补产生的)

这是底下真正发生了什么, 那么一个上层的程序员看见的线程是什么样子的呢? 一个程序员为他的计算任务申请了多个线程, 给每一个线程指定计算任务(比如计算某个人物的行为, 或者向网络发送什么信息), 然后他就可以认为这些线程全都在并行的,同时的执行了。 这是操作系统提供的抽象。

线程安全问题

线程安全问题发生在一个这样的场景下: 多个线程需要读写同一个数据, 而且这样的读写是需要以某种顺序进行的,但是编程人员没有写出足够好的代码保证运行时候一定是这个顺序。 最典型的例子就是抢票问题, 假设两个线程A,B分别服务两个人的订票需求, 这时候内存里有一个数字left表示剩余票数量, 假设我们这个时候只有一张票了,A和B的行为如下

# 节点 0
if(left > 0):
    # 节点 1
    left = left - 1
    # 节点 2
    return "订票成功"
else 
    return "没有剩余票"

正确的线程运行顺序是A先运行(假设A先到), A返回订票成功, B再运行, B这时候发现已经没有剩余票, B订票失败. 但是我们刚刚聊过调度的机制很多时候是时间片轮转, 这个时候就可能出现这样的顺序: A运行到节点1, 这时候发生了线程调度, 切换到B, B查看left == 1, B订票成功, 然后调度回A继续运行, A继续订票, 也订票成功. 我们只有一张票, 但是两个乘客都订票成功了.

有一些机制可以让你避免这样的问题, 它们可能叫做"信号量", "临界区", 或者"原子操作" , 大的思路都是设定一个代码区域, 在这个区域中不允许操作系统调度. 比如这个问题, 只要指定节点0到节点2之间不允许调度发生(不允许线程切换)就能解决. 但是线程安全问题是很难完美解决的. 而且出了问题也很难debug(因为难以重现发生错误时候的线程运行顺序). 这是线程的弊端.(go语言中内置了一个叫做协程的机制, 听说可以解决这个问题, 但是我还没学会)

谈谈cache

从我们的存储结构层级开始


我们刚刚已经谈过, CPU寄存器的读取速度是内存的十倍, 这意味着每次我们想要把数据读入寄存器, 都需要等待很长时间, 粗略的估计, 我们认为读取CPU寄存器的时间不影响CPU指令的执行, 那么我们大概可以认为CPU执行一条指令的时间和读取CPU寄存器的时间相同, 这么估计的话每次从内存读取数据, CPU等待的时间足够它执行十次计算, 这不是我们愿意看见的.

程序的局部性原理

举一个类似的情况, 假设你在图书馆里, 正在为了写你的某一篇论文伤神. 你每次写到不确定的地方就站起来去图书馆的书架上查书, 查书这个动作非常耗费你的时间, 这时候你可能会借走几本你最需要的书放在你手边, 当你需要查书的时候, 优先从手边的书里查.如果把内存里的数据比作图书馆里的藏书, 你借到手边的书就是cache.你能从上图中看见cache的读取速度远快于内存, 就像你翻手边的书总比去图书馆书架上查书要快一样.因为你的论文总是有某个确定的(还很可能是狭窄的)主题, 当你查书的时候, 你借到手边的几本书能覆盖你绝大部分的需求, cache基于同样的原理工作, 某一段代码需要的数据很可能只是内存里的一小部分, 只要这一部分都放进cache里,我们就不用每次都访问内存了, 这样程序运行的速度能大大加快.

程序的局部性原理主要包括两条:

  • 时间局部性, 程序访问过的数据短时间内很可能再次访问
  • 空间局部性, 程序倾向于访问刚刚访问过的数据的附近的数据.

举一个简单的例子, 假如你想把某个列表里的数字打印5遍, 代码如下

T = [1, 2, 3, 4, 5]
for i in range(5):
    for num in T:
        print(num)

时间局部性指的是列表T里的元素1在第一次访问之后马上就要被第二次访问, 我们只要保证第二次访问的时候1在cache里, 就能减少访问内存的时间开销.

同样的, 假设列表T里的元素是顺序摆放在内存里的, 空间局部性指的是访问完元素1之后马上要访问元素2,实际上最好的情况是我们在访问元素1的时候就把整个列表T读入cache中.

你可以把cache理解为一个硬件实现的dict, key是在内存地址, value是数据.每次我们访问内存的时候, 就顺手多拿出附近的数据放在cache里(硬件可以把传输的带宽做大一点, 一次性并行地读取很多数据), 这样就能顾及到空间局部性. 时间局部性是由替换算法决定的, 现在最流行的替换算法是最不频繁替换, 思路是在cache中有一个bit标记某个数据是不是被访问了,这个bit一段时间刷一次零, 每访问一次就置1, 当我们需要覆盖cache里的数据的时候, 覆盖那些对应bit为0的数据. 这个算法能大概地替换出访问不是那么频繁的数据(或者说访问频繁的数据更可能留在cache中) .为什么不真的开一个整数去计数访问次数呢? 因为cache里的存储空间很贵, 而且现在的算法就够好了, 精细地追踪访问情况意味着更多的变量要维护, 意味着时间开销更大.

程序的局部性原理是比时间复杂度更细节一些的算法时间估计思路, 同样的时间复杂度, 实现得更有局部性的算法速度肯定更快. 这个理论可以部分解释为什么都是O(log(n))的算法, 某些排序就是比另一些快. 但是我从来没有见到过在写程序的时候真的需要去考虑程序局部性的项目. 现代编译器已经非常智能, 可以优化一些简单的情况(比如矩阵乘法), 而且很多时候把程序写对就已经不容易了.

补充一个八卦, 上课时候老师聊到, 求交集算法中有一个例子:

求A和B集合的交集, 实际上是这么做的:

对每一个元素a属于A, 查找a是不是在B中.

这样就变成一个查找问题, 在集合B中查找元素a, 这个时候从算法上来说二分查找的速度远超过顺序查找, 但是二分查找访问内存是没有局部性的.(你很容易想象, 二分查找访问是跳来跳去的), 在有cache加成之后,顺序查找的速度会超过二分查找. 搜索引擎中的类似算法都是采用顺序查找.

讲这个事情的老师是个靠谱人, 但是我自己不太相信,可能背景没说清楚, 毕竟复杂度差距摆在那里

谈cache是因为这个东西无处不在, 它是一个通用的解决思路, 面向的是这么一个问题:

我们有两个读写速度有较大差异的存储介质, 我们想要把两种介质组合起来使用, 但是又想要读写速度相对快一些.

说着说着你就会发现存储介质层级表里还有一个类似的关系: 内存 -- 硬盘

没错, 为了提高读写硬盘的速度. 我们也做了一个cache一样的东西, 只是这个东西是软件做的. 操作系统会管理一个叫做页缓存的东西, 存储在内存的某个部分, 每次读写硬盘的时候就做一些刚刚说过cache会做的工作. 实际上你调用write函数, 函数在把你要写的东西写进页缓存里就返回了.(不然你每次调用write函数会等待更长的时间) .这些内容真正写入硬盘的时间是操作系统定的. 这是早年windows安全弹出U盘之前不能拔出U盘的原因, 你申请安全弹出的时候, 操作系统会赶紧把相关的页缓存写进U盘里, 如果你在安全弹出之前就拔出了U盘, 那页缓存里的数据可能还没有写进U盘里, 你以为复制成功的文件实际上可能并没有真正写入U盘.(现在这个问题修复了, windows发现你是U盘之后写就跳过页缓存直接写入U盘了, 所以你大概率试不出来)

好了. 谈完cache之后, 你可能更理解线程切换比进程切换快在哪里了, 从进程A切换到进程B, cache是需要清空的. 这是进程运行独立要求的. 如果cache不清空, B就能访问到A的数据了, 这是我们在设计进程的时候一定要避免的. 从属于同一个进程的线程C和D, 因为他们本来就共享同一个虚拟内存映射, 本来也应该互相能够访问对方使用的数据, 所以这个时候cache是不用清空的.

中断和系统调用

中断和系统调用解决的是计算机和各种外设交互的问题.

我们在进程一节已经说过操作系统能调度进程, 把正在运行的进程停下来, 换另一个进程开始执行. 这在你和朋友尬聊不下去,只好拿出手机疯狂在微博, 微信和哔哩哔哩之间反复跳跃的时候经常发生.

在我之前的叙述中, 你会觉得操作系统是一个无所不能的老大哥, 随时都在监视你的计算机使用, 但是我们谈过, 操作系统只是软件而已, 操作系统想要提供服务, CPU必须跑操作系统的代码, 和普通的进程没什么差别.

那么问题就来了, 在两个进程切换中间, 操作系统确实接管了, 可是它什么时候接管的呢? 或者从硬件的角度上来说. CPU怎么怎么知道需要跑操作系统的代码了?

中断

中断是一个硬件实现的机制, CPU能接收外界的信号, (你可以认为是一个整数), 根据整数在中断向量表中选择一项, 跳转到对应的中断处理函数.

中断是允许其他设备和CPU交互的机制, 每次你点击屏幕, 中断就会发生, 这个时候操作系统介入, 判断你点击的是某个app, 去切换进程.

总结一下, 中断是操作系统接管的机会, 但是不是所有中断都会跳到操作系统接管. 一个中断发生之后CPU跑什么代码是由中断向量表决定的. 这需要硬件工程师和操作系统工程师的协调.

除去人机交互, 中断也广泛应用于硬盘读取, 网卡交互等等事情, 每次读取硬盘就是给硬盘一个读取坐标, 硬盘把数据读出来, 发起中断,告诉CPU它读完了. 这样的好处是CPU在硬盘寻找数据的过程中可以去做别的事情.

系统调用

系统调用是一种特殊的中断. 它发生在应用进程想要操作系统为它提供服务的时候. 比如readwrite函数. 应用不用管一系列的页缓存, 硬盘等等东西, 它只需要调用read, 这个时候软件产生一个中断, 操作系统介入为它完成接下来的事情. 网络相关的事情也是这样实现的, 应用程序只需要调用sendrecv 函数就可以了.像这样, 操作系统告诉你它能够提供什么服务的一类东西就叫做操作系统的API. 因为操作系统是C语言实现的, 提供的这类API一般也是C语言.

文件系统

应用程序读文件是怎么样子的?

with open('D:\\lion_ide\\py_1\\material.txt') as file:
    data = file.read(100)

应该分两步:

  1. 提供一个路径(相对或者绝对), 打开文件.
  2. 提供一个数字, 表示需要读取多少字节.

对应的,用户看见的文件系统应该包括两个部分:

  1. 一颗文件树, 盘符是树根(windows这种允许多个盘符的文件系统就是多颗文件树,Linux就只有一颗文件树 ).文件树的枝丫就是路径, 你总能由一个路径找到一个指定的文件(要不然就能确定它不存在). 你能使用相对路径是因为每个进程会保存一个自己的默认路径, 相对路径是相对它而言, 比如python的相对路径是.py文件所在的路径, C和C++的相对路径是编译链接生成的最后的.exe文件所在路径(当然你使用集成开发环境的话会有一些不一样, visual studio 的默认路径就是它的工程文件夹).
  2. 一个文件的抽象. 文件被抽象成一维的结构. 有一个文件的开头, 有一个EOF结束标志, 中间就是一根数轴. 你读取的时候只需要声明自己想从哪里开始读取, 读取多少个字节. (很多时候你不需要提供自己想从哪里开始读取, 是因为每一个进程都保存一个自己打开文件的当前文件指针, 你不显式提供从哪里开始, 操作系统就自动从保存的文件指针处开始)

这是文件系统提供给你的抽象, 文件系统需要面对的硬件是什么情况?

硬盘的结构可以认为是二维的. 盘片是圆形的, 按照极坐标描述, 沿着极轴的方向划分不同的磁道, 沿着极角划分扇区.读写磁盘的时候提供的就是一个(磁道, 扇区) 二元组, 其实就是一个极坐标下的坐标.

文件系统得想办法在磁盘上做出你看见的抽象.

编辑于 2019-03-11