写点什么

聊聊操作系统——上篇

用户头像
Jerry Tse
关注
发布于: 2020 年 07 月 22 日

0. 前言

对于软件工程师而言,操作系统知识是必备基础知识。接下来我就来介绍一下程序员应该需要了解的操作系统知识。


1. 操作系统之前的世界

也许对于我们来说,无法现象没有操作系统的计算机是什么样子,但是计算机诞生之初确确实实是没有操作系统的。


1.1 硬件三驾马车

我们知道当今的计算机(冯·诺依曼体系)由三部分组成:

  • 中央处理器(CPU):负责计算或任何广义上需要计算的行为

  • 存储(内存和 CPU 缓存):负责存储计算中间值和最终结果

  • 输入输出设备:数据的来源和目的地,任何设备只要满足接口都可以和计算机交互(也很符合 OCP 和 DIP 原则),硬盘、网络、打印机、显示器、键盘、鼠标只要是满足计算机输入输出接口标准的设备,都可以想计算机输入数据或接受数据以实现万千功能。这也是最能体现计算机开发性设计的地方。


计算机的核心需求就是“计算数据”,“数据的输入->数据计算->数据暂存->数据输出”一整套数据计算流程可以通过以上三部分完美实现。


有了“计算数据”的能力,我们就可解决一切需要“计算数据的问题”。我们当前使用计算机做的一切工作都可以理解为“计算数据的问题”。但是先别急,我们刚刚只介绍了硬件基础。接下来要介绍软件,就是告诉计算机如何解决问题的。


1.2 软件进场

硬件只是提供了计算的能力,软件就是描述问题并且告诉计算机如何使用计算来实现实现的需求。软件的基础是 CPU 的可编程性扩展能力,CPU 支持各种指令集,指令集的个数虽然有限,但是指令集排列组合后的结果是无穷的。


最早的程序程序就使用 01 机器语言(CPU 指令集)编写所谓的软件,通过打孔机输入计算机,通过 CPU 直接执行,结果有打孔机输出。整个交互的过程都是串行的,输入之后就需要等待计算机完成计算并且输出结果。这与现在的计算机使用场景大相径庭。是什么让计算机成为一个看上去无所不能的潘多拉的宝盒而不仅仅是一个只会默默算数的铁盒子呢,这一切都是操作系统的功劳。


2. 操作系统的需求

操作系统也是软件,只不过是其他软件运行的基础环境。大部分软件都运行在操作系统之上

2.1 软件治理问题

计算机如果一直采用上面介绍的软件运行方式,显然是不可能的。

如何需要同时运行多个软件,这就有如下问题:

  • 多个软件如何同时运行?如何使用 CPU?

  • 如何共同使用计算机上的存储空间(内存管理、文件系统的需求)?

  • 如何共同使用同一个外部设备(设备管理的需求)?

  • 如何相互通讯,如何进行数据交换(进程间通讯、共享内存的需求)?

  • 如果有恶意软件运行危害正常软件如何处理?(安全管理的需求)?

这些就是操作系统要解决的问题,多个软件运行时的软件治理问题。

2.2 基础编程接口

显然如果直接针对计算机使用机器语言编程是痛苦,操作系统提供了基础的编程接口,帮我们屏蔽了底层硬件的复杂性。


3. OOP 的体现

广义上看设计原则不止体现在面向对象的设计中,我们也能从计算机和操作系统的身上找到他的影子。

从计算机的组成到操作系统的需求,我们都可以看到软件设计模式中的各个原则体现。

  • 计算机三部分采用分模块设计,符合 SRP,各司其职

  • 输入输出设备和 CPU 指令集设计符合 OCP,为计算机软件和硬件的扩展性奠定了基础

  • 操作系统定义了软件编程接口及运行环境,符合 DIP 原则,提供了一个基础的软件抽象世界和编程架构。我们的软件只要按照接口行事并满足操作系统要求,我们就能让计算机为我所用


4. 抽象


同样我们可以看到抽象的重要,我们抽象了“计算”作为计算机的核心能力,他解决现实中一切“计算”问题,我们抽象了“软件”作为某一个具体问题代表,通过软件向内告诉计算机问题如何解决,向外提供解决的过程和结果。正式通过软件来描述“计算”什么问题,如何呢解决的。我们抽象了“操作系统”作为软件运行的基础。操作系统抽象了计算机硬件使用的原则和接口(对硬件的一进步抽象),软件就是遵循这些原则才能被计算机运行并使用计算机能力解决外部世界问题。


抽象能力作为一种核心能力是对世界的高纬度总结概括,是解决一切问题的钥匙。


5. 进程


5.1 多任务处理能力

多任务处理能力是操作系统软件治理的核心能力。

CPU 是独占资源,同一时间只能执行一条指令。要想同时执行不同的指令,实现多任务处理,有两种方式:

  • 硬件扩容:增加计算机 CPU 个数或增加 CPU 内核心数。物理扩容能力终将优先,想想一下当前主流 CPU 通常为 8 核心,系统只能同时处理八个任务,这远远小于我们日常工作所需要计算机的多任务处理能力。

  • 分时操作系统(软件解决):操作系统将 CPU 的时间切分成一段一段的时间片,每个时间片运行某一个任务。各个任务在各个时间片上交替执行,在时间片足够小的情况下,看似任务并行执行。通过这种方式,即使我们只有一个 CPU 核心,也能实现多任务处理能力。

现代操作系统通常都是分时操作系统。


5.2 概念

进程是一种抽象,是操作系统对运行中的程序的抽象,是操作系统中一个执行单元。

我们的代码不运行的时候只是静静的存储于硬盘中,是静态的。一旦被运行就变成动态的进程,进程可以被操作系统调度,使用 CPU 时间片执行指令。

进程也是操作系统最基本的隔离单元:从进程内部看,进程认为自己有独占 CPU、内存和输入输出设备。但实际操作系统同时会有多个进程运行,且进程间是隔离的,不同的进程有不同的地址空间,相互不会影响。


5.3 进程的状态

因为进程会被操作系统调度执行,所以不同的调度阶段对应进程状态,常见有三状态、五状态、七状态等不同状态粒度,我们介绍最基础的三状态

定义

  • 运行:当一个进程在 CPU 运行时(获取 CPU 执行时间,正在执行程序指令),就处于运行状态。某一时刻处于运行中的状态小于等于 CPU 的核数(CPU 是独占资源)

  • 就绪:当进程获得了除 CPU 以为的一切资源,只要得到 CPU 即可运行,就称为就绪状态。通常系统中有多个进程在就绪状态等待运行。

  • 阻塞:当一个进程正在等待某一时间发生(等待 i/O 完成,等待锁,等待被唤醒)而终止运行,这是就是吧 CPU 分配给进程也无法执行。

状态转换


状态转换就是体现进程是活动的过程。


  • 运行态->阻塞态:等待使用资源。如等待 I/O 传输,等待人工干预。​

  • 阻塞态->就绪态:资源得到满足。如 I/O 传输完成,人工干预完成。​

  • 运行态->就绪态:CPU 运行时间片用尽,让位于其他进程。​

  • 就绪态->运行态:CPU 可用,系统调度一个就绪态进程运行。


运行态和阻塞态相互转换,这也是分时系统线程调度最常见的场景,系统进程调度的过程就是一直从就绪态进程中选择一个进程分配 CPU 时间片执行。

运行态、阻塞态、就绪态当方向转换。线程被时间阻塞马上进入阻塞态,阻塞时间处理完成也不能直接进入运行态,只能进入就绪态待系统调用。


6. 线程


6.1 概念

线程的出现,则是因为操作系统发现同一个程序内还是会有多任务(并发)的需求,这些任务处在相同的地址空间,彼此之间相互可以信任



我们以常用的 Tomcat 容器举例,Tomcat 是一个运行在 Java 虚拟机上的程序,启动 Tomcat 就是启动 Java 虚拟机并执行 Tomcat 代码。同时 Tomcat 也会有并发需求(用户访问是并行的),这些并发需求通过在容器内新建线程的方式实现。


6.2 线程状态及转换

和进程一样,线程也有三种基础状态。这里我们以 Java 语言中 Thread 状态介绍。

6 种状态

  • New(新建)

  • Runnable(可运行)

  • Blocked(被阻塞)

  • Waiting(等待)

  • Timed waiting(计时等待)

  • Terminated(被终止)

  1. New:new Thread()后线程的状态就是新建。

  2. Runnable:线程一旦调用start()方法,无论是否运行,状态都为 Runable,注意 Runable 状态指示表示线程可以运行,不表示线程当下一定在运行,线程是否运行由虚拟机所在操作系统调度决定。

  3. 被阻塞:线程试图获取一个内部对象的Monitor(进入synchronized方法或synchronized块)但是其他线程已经抢先获取,那此线程被阻塞,知道其他线程释放Monitor并且线程调度器允许当前线程获取到Monitor,此线程就恢复到可运行状态。

  4. 等待:当一个线程等待另一个线程通知调度器一个条件时,线程进入等待状态。

  5. 计时等待:和等待类似,某些造成等待的方法会允许传入超时参数,这类方法会造成计时等待,收到其他线程的通知或者超时都会恢复到可运行状态。

  6. 被终止:线程执行完毕正常结束或执行过程中因未捕获异常意外终止都会是线程进入被终止状态。

状态转换



新建--->可运行--->被终止

新建--->可运行--->被阻塞--->可运行--->被终止

新建--->可运行--->等待--->可运行--->被终止

新建--->可运行--->计时等待--->可运行--->被终止


更详细的介绍可以读一下我过往的一篇文章:《一文读懂Java线程状态转换》

6.3 内存共享

线程共享内存中部分地址,每个进程也拥有自己的内存空间。

堆空间:线程共享,进程中所有线程共享的空间,Java 中堆空间存储对象。

堆栈空间:线程独享,进程中所有线程都有各自的堆栈,使用栈帧这种数据结构存储函数的局部变量,使用堆栈先进后出的特点,通过栈帧入栈出栈完成函数调用时本地变量执行环境的切换工作。


6.4 线程安全

堆空间因为线程间共享,多线程同时修改对空间中存储的对象的成员变量或类变量的时候有可能出现线程安全问题,产生意料之外的结果。


我们以计数器举例:

public class Counter {    private int count;    public void add(){        count+=1;    }}
复制代码

注:add 方法内代码对了多线程共享的变量进行了操作,所以称为临界区

如果两个线程按照上图的顺序执行计数器 Counter 中 add 方法,我们期望的是 count 值为 2,但是实际执行后 count 值为 1。这就出现了意料之外于正常逻辑不相符的情况

  • 一段代码在多线程并发的场景下执行与单线程场景执行产生不同的结果,就是线程不安全。

  • 方法内的局部变量线程独享,没有线程安全问题。对象中的成员变量和类变量线程共享,有线程安全问题。


6.5 线程安全解决方法

1. 使用不可变对象

不可变对象就是对象中的成员变量和类变量都被 fianl 标注,且方法的入参和出参都是用保护性复制方式处理。加上这些约束,就可以保证这个对象一旦被创建,它的属性无法被修改。

线程安全就是因为线程间共享的对象属性被多线程间同时修改,一旦一个对象的属性不能被修改,也就没有线程安全问题。

Java 中 String,Integer 等类都是不可变类,他们都是线程安全的。但是实际使用中我们会频繁的创建新对象,这也是一种损耗。

2. 使用 CPU 中 CAS 原语

CAS 上 Compare And Swap 的简写,CPU 指令集保证的原子级操作 CAS(var,e,n),当我们要更新一个变量的值的时候,需要传入预期的值和修改后的值,如果当前变量的值等于预期的值就执行更新操作,如果当前变量的值不等于预期值,就不执行更新。以上整个逻辑由 CPU 指令保证原子性。

不等于预期通常说明当前线程在修改的时候有其他线程也修改了同样的值,通常的处理方式循环操作,知道更新成功。


public final int getAndAddInt(Object var1, long var2, int var4) {        int var5;        do {            var5 = this.getIntVolatile(var1, var2);        } while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));
return var5; }
复制代码

这里截取一段 JDK 中 unsafe 类中的代码说明。代码中一旦 compareAndSwapInt 不成功就重新读取最新值最为希望值继续重试,指导 compareAndSwapInt 执行成功为止。


CAS 的问题:
  • 如果多线程间并发较严重,CAS 会多次循环。

  • ABA 问题,可以使用不重复的版本解决。


3. 使用锁机制

public class Counter {    private int count;    public synchronized void add(){        count+=1;    }}
复制代码

我们改造了一下计数器的例子,在临界区代码中增加了锁,保证同一时刻只有一个线程可以进入临界区,其他线程会进入阻塞状态。

锁会带来以下问题:
  • 加锁操作本身的性能消耗

加锁本事也是操作,也会带来损耗。试想原来大门敞开随便进,现在进门就要看一下门是不是锁上的,没有上锁就随手关门上锁。即使没有尾随的人(并发情况),也要完成这一系列操作,试想同样一段时间不需要加锁和加锁的情况,即使都没有并发出现,不加锁由于操作少,进入的人一定更多。

  • 死锁问题

在使用多把锁的情况下由于编码问题造成的锁之间相互等待造成死锁。其实这个问题可以避免,但是现实情况下尝尝会发生。


虽然锁会带来一些问题,但是现实中我们也经常使用,毕竟多线程下代码逻辑正确是我们首先要保证的。


关于 CAS 和锁在解决线程安全时候对性能的影响,可以参考我的一篇旧文《Synchronized方式和CAS方式实现线程安全性能思考》,在此不再赘述。


4. 分而治之

除了上面的具体方法,我们还需要一点解决线程问题的思路,分而治之就是常用的一种思璐。

例如,Java 中 ConcurrentHashMap 将数据分段加锁。数据分段的思路降低了并发修改同一数据的可能性,降低了线程冲突的概率,提高了方法执行效率。

读写锁也是同样的思路,将读和写行为分开处理就可以提升读多写少场景的效率。

类似的场景还有 CopyOnWrite 模式。

以上的方式都是通过分而治之的思路,要么将多线程下访问的数据分类,要么将多线程访问动作进行分类,分开处理后就降低并发冲突的概率,提高程序运行效率。


5. 避免多线程场景

避免多线程场景同样是一种思璐,如果我们绕开多线程场景,同样可以解决多线程问题。我们列举一下具体实例。


像 AKKA 这种响应式异步框架。相同的 Actor 共享一个线程。所以 Actor 都在一个线程中串行执行,一个线程就不存在线程安全问题。

不同的 Actor 可以在不同的线程中运行,但是 Actor 间不共享状态,只通过传递不可变对象来传递消息,同样不存在线程安全问题。


再例如,Linux 下 select 和 epoll 都是通过一个线程循环处理网络 IO,虽然本质上这样设计不是为了解决线程安全问题而是为了避免开启过多的线程造成资源占用。但是这种方式也确实是不在多线程下运行的现实中应用。


现实场景中多线程情况下线程安全的复杂性远远超过我们的预期,且通常这类问题都难以在测试中发现,及时发现也难以复现。线上一旦出现也会非常棘手,且通常需要很长时间的定位,也很难分析并解决。大家一定在实际工作中深有体会。

类似于 AKKA 这种异步的编程框架替我们降低了多线程开发难度,但是响应式回调方式的编程模式会让我们的代码难以理解。

所以线程安全可能是一个长期的问题,需要我们继续探索解决。


7. 总结

本篇我们以计算机系统硬件开篇,分别介绍了操作系统关键的概念,我们用了比较多的篇幅介绍进程和线程的概念。操作系统的基础知识,尤其是线程和进程的知识对软件工程师非常重要,他是我们能不能写出高性能代码的关键因素。下篇文章中我们会更进一步介绍进程和线程在现实场景中,尤其是服务器应用开发场景下的问题,并及进一步探讨解决方案。

发布于: 2020 年 07 月 22 日阅读数: 138
用户头像

Jerry Tse

关注

还未添加个人签名 2018.11.02 加入

还未添加个人简介

评论

发布
暂无评论
聊聊操作系统——上篇