• 【操作系统】模块四:进程和线程


    在这里插入图片描述

    文章目录

    进程和线程

    • 进程(Process),顾名思义就是正在执行的应用程序,是软件的执行副本。
    • 而线程是轻量级的进程,进程是分配资源的基础单位。而线程很长一段时间被称作轻量级进程(Light Weighted Process),是 程序执行的基本单位。
    • 有的程序语言或者应用,用户(程序 员)自己还实现了线程。相当于操作系统调度主线程,主线程的程序用算法实现子线程,这种情况我们 称为用户级线程。Linux PThread API 就是用户级线程,KThread API 则是内核级线程。

    分时和调度 :

    • 分时技术在这里插入图片描述
    • 分配时间片段
      在这里插入图片描述

    进程和线程的状态

    在这里插入图片描述
    (线程)运行的过程,会经历以下 3 个状态:

    • 进程(线程)创建后,就开始排队,此时它会处在“就绪”(Ready)状态;
    • 当轮到该进程(线程)执行时,会变成“运行”(Running)状态;
    • 当一个进程(线程)将操作系统分配的时间片段用完后,会回到“就绪”(Ready)状态。
    • 现代操作系统调度线程

    在这里插入图片描述

    • 一个进程(线程)会等待磁盘读取数据,或者等待打印机响应,此时进程自己会进入“阻塞” (Block)状态。因为这时计算机的响应不能马上给出来,而是需要等待磁盘、打印机处理完成后,通过中断通知 CPU, 然后 CPU 再执行一小段中断控制程序,将控制权转给操作系统,操作系统再将原来阻塞的进程(线程) 置为“就绪”(Ready)状态重新排队。
      而且,一旦一个进程(线程)进入阻塞状态,这个进程(线程)此时就没有事情做了,但又不能让它重 新排队(因为需要等待中断),所以进程(线程)中需要增加一个“阻塞”(Block)状态。
      在这里插入图片描述
    • 因为一个处于“就绪”(Ready)的进程(线程)还在排队,所以进程(线程)内的程序无法执行, 也就是不会触发读取磁盘数据的操作,这时,“就绪”(Ready)状态无法变成阻塞的状态,因此下图中没 有从就绪到阻塞的箭头。
    • 而处于“阻塞”(Block)状态的进程(线程)如果收到磁盘读取完的数据,它又需要重新排队,所以它也 不能直接回到“运行”(Running)状态,因此下图中没有从阻塞态到运行态的箭头、
      在这里插入图片描述

    进程和线程的设计

    进程和线程的表示

    在这里插入图片描述

    • 描述信息:这部分是描述进程的唯一识别号,也就是 PID,包括进程的名称、所属的用户等。

    • 资源信息:这部分用于记录进程拥有的资源,比如进程和虚拟内存如何映射、拥有哪些文件、在使 用哪些 I/O 设备等,当然 I/O 设备也是文件。

    • 内存布局:操作系统也约定了进程如何使用内存。如下图所示,描述了一个进程大致内存分成几个 区域,以及每个区域用来做什么。 每个区域我们叫作一个段。
      在这里插入图片描述

    • 操作系统还需要一张表来管理线程,这就是线程表。线程也需要 ID, 可以叫作 ThreadID。然后线程需 要记录自己的执行状态(阻塞、运行、就绪)、优先级、程序计数器以及所有寄存器的值等等。线程需 要记录程序计数器和寄存器的值,是因为多个线程需要共用一个 CPU,线程经常会来回切换,因此需要 在内存中保存寄存器和 PC 指针的值。创建进程开销大、成本高;创建线程开销小,成本低

    隔离方案

    • 操作系统中运行了大量进程,为了不让它们互相干扰,可以考虑为它们分配彼此完全隔离的内存区域, 即便进程内部程序读取了相同地址,而实际的物理地址也不会相同。
    • 对于一个进程的多个线程来说,可以考虑共享进程分配到的内存资源,这样线程就只需要被分配执行资 源。

    进程(线程)切换

    • 进程(线程)在操作系统中是不断切换的,现代操作系统中只有线程的切换。 每次切换需要先保存当前 寄存器的值的内存,注意 PC 指针也是一种寄存器。当恢复执行的时候,就需要从内存中读出所有的寄存器,恢复之前的状态,然后执行。
      在这里插入图片描述
      五个步骤:
      1.当操作系统发现一个进程(线程)需要被切换的时候,直接控制 PC 指针跳转是非常危险的事情, 所以操作系统需要发送一个“中断”信号给 CPU,停下正在执行的进程(线程)。
      2.当 CPU 收到中断信号后,正在执行的进程(线程)会立即停止。注意,因为进程(线程)马上被 停止,它还来不及保存自己的状态,所以后续操作系统必须完成这件事情。
      3.操作系统接管中断后,趁寄存器数据还没有被破坏,必须马上执行一小段非常底层的程序(通常是 汇编编写),帮助寄存器保存之前进程(线程)的状态。
    1. 操作系统保存好进程状态后,执行调度程序,决定下一个要被执行的进程(线程)。
    2. 最后,操作系统执行下一个进程(线程)。

    在这里插入图片描述

    • 一个进程(线程)被选择执行后,它会继续完成之前被中断时的任务,这需要操作系统来执行一 小段底层的程序帮助进程(线程)恢复状态。
      在这里插入图片描述
    • 进程(线程)中断后,操作系统负责压栈关键数据(比如寄 存器)。恢复执行时,操作系统负责出栈和恢复寄存器的值。

    多核处理

    在这里插入图片描述

    • 并 发(concurrent)指的在一段时间内几个任务看上去在同时执行(不要求多核);
    • 并行: parallel,任务必须绝对的同时执行(要求多核)。
    • 比如一个 4 核的 CPU 就好像拥有 4 条流水线,可以并行执行 4 个任务。一个进程的多个线程执行过程 则会产生竞争条件(->锁),因为操作系统提供了保存、恢复进 程状态的能力,使得进程(线程)也可以在多个核心之间切换。

    创建进程(线程)的 API

    在这里插入图片描述

    • 用户想要创建一个进程,最直接的方法就是从命令行执行一个程序,或者双击打开一个应用。
    • 站在设计者的角度,你可以这样思考:首先,应该有 API 打开应用,比如可以通过函数打开某个应用; 另一方面,如果程序员希望执行完一段代价昂贵的初始化过程后,将当前程序的状态复制好几份,变成 一个个单独执行的进程,那么操作系统提供了 fork 指令。
    • 每次 fork 会多创造一个克隆的进程,这个克隆的进程,所有状态都和原来的进程一样,但是 会有自己的地址空间。如果要创造 2 个克隆进程,就要 fork 两次。
    • 操作系统提供了启动新程序的 API。操作系统提供了启动新程序的 API。

    原子操作

    • 在多线程 + 多核环境会造成竞争条件。
      在这里插入图片描述

    竞争条件

    • 竞争条件就是说多个线程对一个资源(内存地址)的读写存在竞争,在这种条件下,最后这个资源的值 不可预测,而是取决于竞争时具体的执行顺序。
    • 如两个线程并发执行i++。那么可以有下面这个操作顺序,假设执行前i=0:
    • 共享资源的程序片段 —— 临界区

    在这里插入图片描述

    • i++这段程序访问了共享资源,也就是变量i,这种访问共享资源的程序片段我们称为临界区。在临界 区,程序片段会访问共享资源,造成竞争条件,也就是共享资源的值最终取决于程序执行的时序,因此 这个值不是确定的。

    解决竞争问题

    • 一种方案就是不要让程序同时进入临界区,这个方案叫作互斥。
    • 一种是旨在避免竞争条件,比如 ThreadLocal cas 指令以及 乐观锁。

    避免临界区

    在这里插入图片描述

    Cas(Compare And Swap )指令

    CPU 的指令,让i++成为一个原子操作。 这个指令的作用是更新一个内存地址的值,比如把i更新为i+1,但是这个指令明确要求使用者必须确定知道内存地址中的值是多少。比如一个线程想把i100更新到101,线程必须明确地知道现在 i100,否则就会更新失败。 但是使用Cas指令 上面的程序还是会错
    在这里插入图片描述

    • cas(&oldValue, expectedValue, targetValue) &符号代表这里取内存地址。
    • 改进 while(!cas (&i, i, i+1 ) ) { ; } 如果 cas 返回 false,那么会尝试再读一次 i 的值,直到 cas 成功

    tas 指令

    Test-And-Set 指令(tas)。tas 指令的目标是设置一个内存地址的值为 1,它的工作原理和 cas 相似。首先比较内存地址的数据和 1 的值,如果内存地址是 0,那么把这个地址置 1。如果是 1,那么失败。
    所以你可以把 tas 看作一个特殊版的cas,可以这样来理解:

    tas (&lock) {
     return cas(&lock, 0, 1)
    }
    
    • 1
    • 2
    • 3

    锁(lock),目标是实现抢占(preempt)。就是只让给定数量的线程进入临界区。锁可以用tas或者 cas来实现。
    如果希望同时只能有一个线程执行i++,伪代码可以这么写:

     enter();i++; leave();
    
    • 1

    cas实现enter和leave函数

     int lock = 0;
    enter(){
     while( !cas(&lock, 0, 1) ) { //1:有线程进入临界区
     	 ; //线程就会一直执行cas操作,直到锁被释放。
    	   }
    	} 
    	
    leave() {
     lock = 0;// 没有线程进入临界区
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10

    语言级锁的实现

    • cas 更底 层,用 cas 解决问题优化空间更大。但是用锁解决问题,代码更容易写——进入临界区之前 lock,出去 就 unlock。 从上面这段代码可以看出,为了定义锁,我们需要用到一个整型。如果实现得好,可以考虑这个整数由语言级定义
    //实现让用户传递一个变量过去
    int lock = 0;
    enter(&lock); 
    //临界区代码 
    eave(&lock);
    
    • 1
    • 2
    • 3
    • 4
    • 5

    自旋锁

    • 自旋锁:线程不会主动释放资源。比如代码不断在 CPU 中执行指令,直到锁被其他线程释放。
    • 优点就是不会主动发生 Context Switch,也就是线程切换,因为线程切换比较消耗时间。
    • 缺点也非常明显,比较消耗 CPU 资源。如果自旋锁一直拿不到锁,会一直执行。
    enter() {
     while( !cas(&lock, 0, 1) ) {
     	 // 什么也不做
    	} 
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5

    wait 操作

    enter() { 
    	while( !cas(&lock, 0, 1) ) { //cas 失败后
    	 // sleep(1000ms); 
    	  wait(); // 等待一个信号——直到另一个线程调用notify方法,通知这个线程结 束休眠。
    	} 
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6

    生产者消费者模型

    • 一个合理的实现就是生产者消费者模型。 wait 是一个生产者,将当前线程挂到一个等待队列上,并休 眠。notify 是一个消费者,从等待队列中取出一个线程,并重新排队 。 将API封装。

    信号量

    竞争条件的版本:
    up(&lock){ 
    	while(!cas(&lock, lock, lock+1)) { }
    } 
    down(&lock) {
     while(!cas(&lock, lock, lock - 1) || lock == 0){}
    }
    //up和down。up将lock增 1,down将lock减 1。
    
    int lock = 2;
    down(&lock); 
    // 临界区 
    up(&lock);
    //lock等于 1,第 2 个线程还可以进入。
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 当lock初始值为 1 的时候,这个模型就是实现互斥(mutex)。如果 lock 大于 1,那么就是同时允许 多个线程进入临界区。这种方法,我们称为信号量(semaphore)

    信号量实现生产者消费者模型

    int empty = N; // 当前空位置数量
    int mutex = 1; // 锁 
    int full = 0; // 当前的等待的线程数 
    wait(){ down(&empty); down(&mutex); insert(); up(&mutex); up(&full);} // 生产者
    notify(){ down(&full); down(&mutex); remove(); up(&mutex); up(&empty)}// 消费者
    insert(){
    wait_queue.add(currentThread); 
    yield(); //交出当前线程的控制权,当前线程休眠
    }
    remove(){
    thread = wait_queue.dequeue(); 
    thread.resume(); // 恢复 
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13

    死锁问题

    在这里插入图片描述
    在这里插入图片描述

    • 两个线程互相等待对方获得的锁,就会发生死锁。可以理解成一个环状的依赖关系。
    int lock1 = 0;
    int lock2 = 0; 
    // 线程1 
    enter(&lock1); enter(&lock2); leave(&lock1); leave(&lock2); 
    // 线程2 
    enter(&lock2); enter(&lock1); leave(&lock1); leave(&lock2)
    //上面的程序,如果是按照下面这个顺序执行,就会死锁:
    线程1enter(&lock1); 
    线程2enter(&lock2); 
    线程1enter(&lock2)
    线程2: enter(&lock1) // 死锁发生,线程1、2陷入等待
    
    上面程序线程 1 获得了lock1,线程 2 获得了lock2。接下来线程 1 尝试获得lock2,线程 2 尝试获 得lock1,于是两个线程都陷入了等待。这个等待永远都不会结束,我们称之为死锁
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13

    分布式环境的锁

    • 当用户并发的访问这个接口,是会发生竞争条件的。 因为程序已经不是在同一台 机器上执行了,解决方案就是分布式锁。实现锁,我们需要原子操作。 在分布式的环境下,需要其他工具 提供分布式的原子操作
    • 1.从 Redis 读出当前库存; 2. 计算库存 -1; 3. 更新 Redis 库存。

    除了上锁还有哪些并发控制方法? ——> 悲观锁和乐观锁

    同步的一种方式,就是让临界区互斥。 这种方式,每次只有一个线程可以进入临界区.
    +悲观锁: 让临界区互斥的方法(对临界区上锁),具有强烈 的排他性,对修改持保守态度,我们称为悲观锁(Pressimistic Lock) ,通常的上锁就是指悲观锁。MySQL 的表锁、行锁、Java 的锁,本质是互斥 (mutex)。

    • 乐观锁: 与悲观锁相反,基于 乐观锁的应用就是版本控制工具 Git,Git 允许大家一起编辑,将结果先存在本地,然后都可以向远程仓 库提交,如果没有版本冲突,就可以提交上去。这就是一种典型的乐观锁的场景,或者称为基于版本控 制的场景。
      在这里插入图片描述

    在这里插入图片描述

    购物车的类比

    在这里插入图片描述

    去中心化方案:区块链的类比

    • DNS系统:使用一个分级缓存的策略。
      在这里插入图片描述

    解决最基本的信用问题

    在这里插入图片描述

    解决货币和库存的问题

    • 区块链结构 : 区块链构成了一个基于历史版本的事实链,前一个版本是后一个版本的历史。
    • 每个 Block 下面可以存一些数据,每个 Block 知道上一个节点是谁。每 个 Block 有上一个节点的摘要签名。当一个节点被删除 , 那么它会影响对应的节点。区块链一旦写入就不能修改,这样可以防止很多欺诈行为。
      在这里插入图片描述

    解决并发问题

    假设全球有几十亿人都在下单。那么每次下单,需要创建新的一个 Block。这种情况,会导致最后面的 Block,开很多分支。

    • 最傻的方案就是用锁解决,比如用一个集中式的办法,去 接收所有的请求,这样就又回到中心化的设计。
      在这里插入图片描述
    • 高明的办法,就是开一个分支,这个分支只管理它该管理的地方。先维护自己的 Block-Chain,等待合适的时机,再去合并到主分支上;如果合并不进去就废除该分支。
      在这里插入图片描述

    线程的调度

    先到先服务 First Come First Service

    每处理一个称为一个作业(Job)。处 理作业最容易想到的就是先到先服务(First Come First Service,FCFS),也就是先到的作业先被计 算,后到的作业,排队进行。用到一个叫作队列的数据结构,具有先入先出(First In First Out,FIFO)性质。先进入队列 的作业,先处理,因此从公平性来说,这个算法非常朴素。另外,一个作业完全完成才会进入下一个作 业,作业之间不会发生切换,从吞吐量上说,是最优的——因为没有额外开销

    短作业优先Shortest Job First, SJF

    从另外一个角度来审视短作业优先 的优势,就是平均等待时间:平均等待时间 = 总等待时间/任务数

    优先级队列(PriorityQueue)

    优先级队列的一种实现方法就是用到了堆(Heap)这种数据结构,更最简单的实现方法,就是每次扫描 一遍整个队列找到优先级最高的任务。也就是说,堆(Heap)可以帮助你在 O(1) 的时间复杂度内查找到最大优先级的元素。
    例如应急的任务可以赋予一个更高的优先级,普通任务可以在等待时间(W) 和预估执行时间 (P)中,找一个数学关系来描述。优先级 = W/P

    抢占

    抢占就是把执行能力分时,分成时间片段。 让每个任务都执行一个时间片段。如果在时间片段内,任务完成,那么就调度下一个任务。如果任务没有执行完成,则中断任务,让任务重新排队,调度下一个任务。
    抢占结合优先级队列能力,这就构成了一个基本的线程调度模型。线程相对于操作系统是排队到来的,操作系统为每个到来的线程分配一个优先级,然后把它们放入一个优先级队列中,优先级最高的线程下一个执行。
    在这里插入图片描述
    颜色代表被调度线程的时间片段。调度程序可以考虑实现为一个单线程模型,这样不需要考虑竞争条件。
    缺点 : 如果一个线程优先级非常高,其实没必要再抢占。无法处理最短优先的抢占。

    多级队列模型

    多级队列,就是多个队列执行调度
    在这里插入图片描述

    • 只要上层队列有任务,下层队列就会让出执行权限。
      在这里插入图片描述
    • 实际操作中,可以有 n 层,一层层把大任务筛选出来。 最长的任务,放到最闲的时间去执行。要知道, 大部分时间 CPU 不是满负荷的。

    死锁 与饥饿

    哲学家就餐问题

    圆桌上有 5 份意大利面和 5 份叉子。哲学家比较 笨,他们必须拿到左手和右手的 2 个叉子才能吃面。哲学不饿的时候就在思考,饿了就去吃面,吃面的 必须前提是拿到 2 个叉子,吃完面哲学家就去思考。
    在这里插入图片描述

    问题的抽象

    在这里插入图片描述

    enum PHIS { THINKING, HUNGRY, EATING } // 枚举 状态
    
    void think() throws InterruptedException {  // 思考
    System.out.println(String.format("Philosopher %d thinking...", id)); 
    Thread.sleep((long) Math.floor(Math.random()*1000)); 
    this.state = PHIS.HUNGRY;
    } 
    
    void eat() throws InterruptedException {
     synchronized (forks) { // eat方法依赖于forks对象的锁,相当于eat方法这里会同步——因为这里有读取临界区操作做。 
      if(forks[LEFT(id)] == id && forks[id] == id) { this.state = PHIS.EATING;
    	} else {
     	return;
    		}
    	} Thread.sleep((long) Math.floor(Math.random()*1000)); // Thread.sleep依然用于描述eat方法的时间开销
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16

    synchronized 关键字 : 加锁
    sleep方法没有放到synchronized内是因为在并发控制时,应该尽量较少锁的范围,这样可以增加更大的并发量。

    在这里插入图片描述

    死锁(DeadLock)和活锁(LiveLock)

    //每个哲学家用一个while循环表示,
    while(true){ 
    think();
     _take(LEFT(id));
      _take(id);
       eat();
        _put(LEFT(id));
         _put(id);
    }
     void _take(id){  
     while(forks[id] != -1) {
      Thread.yield(); 
      }
       Thread.sleep(10); // 模拟I/O用时
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15

    _take可以考虑阻塞,直到哲学家得到叉子。上面程序我们还没有进行并发控制,会发生竞争条件。

    在这里插入图片描述
    在这里插入图片描述
    在这里插入图片描述

    • 死锁(Deadlock),这是一种饥饿(Starvation)**的形式。从概念上说,死锁是线程间互相等待资源,但是没有一个线程可以进行下一步操作。饥饿就是因为某种原因导致线程得不到需要的资源,无法继续工作。死锁是饥饿的一种形式,因为循环等待无法得到资源。哲学家就餐问题,会形成一种环状的死锁(循环依赖).
    • 死锁有 4 个基本条件。
        1. 资源存在互斥逻辑:每次只有一个线程可以抢占到资源。这里是哲学家抢占叉子。
        1. 持有等待:这里哲学家会一直等待拿到叉子。
        1. 禁止抢占:如果拿不到资源一直会处于等待状态,而不会释放已经拥有的资源。
        1. 循环等待:这里哲学家们会循环等待彼此的叉子。
    • 饥饿 : 线程长期拿不到需要的资源, 无法进行下一步操作。
    • 要解决死锁的问题,可以考虑哲学家拿起 1 个叉子后,如果迟迟没有等到下一个叉子,就放弃这次操作。 如Java 提供tryLock的方法是拿不到锁,就报异常,并可以依据这个能力开发释放已获得资源的能力。
    • 我们会碰到一个叫作活锁(LiveLock)的问题。LiveLock 也是一种饥饿。可能在某个时刻,所有哲学及都拿起了左手的叉子,然后发现右手的叉子拿不到,就放下了左手的叉子——如此周而复 始,这就是一种活锁。所有线程都在工作,但是没有线程能够进一步——解决问题。

    解决方案

    //我们用synchronized构造了一种排队的逻辑。而 哲学家,每次必须拿起所有的叉子,吃完,/
    //再到下一哲学家。 这样并发度是 1,同时最多有一个线程在 执行。 
    //这样的方式可以完成任务,但是性能太差。
    while(true) {
     synchronized(someLock) {
      think();
       _take(LEFT(id));
        _take(id);
        eat();
       _put(LEFT(id));
       _put(id);
    	} 
    }
    
    // 同时拿起,放下过程也同时放下
    while(true){ think(); synchronized(someLock) { _takeForks();
    		} eat(); synchronized(someLock) { _puts();
    	}
    }
    void _takeForks(){ if( forks[LEFT(id)] == -1 && forks[id] == -1 ) { forks[LEFT(id)] = id; forks[id] = id;
    }
    }
    void _puts(){ if(forks[LEFT(id)] == id) forks[LEFT(id)] = -1;
    if(forks[id] == id) forks[id] = -1;
    }
    
    //think函数没有并发控制,一个哲学家要么拿起两个叉子,要么不拿起,这样并发度最 高为 2(最多有两个线程同时执行)。
    //而且,这个算法中只有一个锁,因此不存在死锁和饥饿问题。
    
    
    // 优化 直接把叉子转让给另一个哲学家
    void _transfer(int fork, int philosopher) {
     forks[fork] = philosopher; dirty[fork] = false;
    }
    //只要对方不在EATING,就可以考虑转让叉子。
    //最后是对 LiveLock 的思考,为了避免叉子在两个哲学家之间来回转让,我们为每个叉子增加了一个 dirty属性。
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36

    进程间通信

    什么是进程间通信?

    进程间通信(Intermediate Process Communication,IPC)。所谓通信就是交换数据。
    在这里插入图片描述

    管道

    • 管道提供一种重要的能力 : 组织计算 。 进程不用知道管道存在,因此管道的设计是非侵入的。程序员可以先着重在程序本身的设计,只需要预留响应管道的接口,就可以利用管道的能力。
      匿名管道
    进程1 | 进程2 | 进程3 | mysql -u... -p | 爬虫进程 
     用管道构建一个微型 爬虫然后把结果入库 达到用shell执行MySQL语句
    
    • 1
    • 2

    在这里插入图片描述

    • 上面的程序将两个进程的临时结果都同时重定向到 namedpipe,相当于把内容合并了再找机会处理。再 比如说,你的进程要不断查询本地的 MySQL,也可以考虑用命名管道将查询传递给 MySQL,再用另一 个命名管道传递回来。这样可以省去和 localhost 建立 TCP 3 次握手的时间。
    • 管道的核心是不侵入、灵活,不会增加程序设计负担,又能组织复杂的计算过程。

    本地内存共享

    • 同一个进程的多个线程本身是共享进程内存的。 这种情况不需要特别考虑共享内存。如果是跨进程的线 程(或者理解为跨进程的程序),可以考虑使用共享内存。内存共享是现代操作系统提供的能力, Unix 系操作系统,包括 Linux 中有 POSIX 内存共享库——shmem
    • 共享内存的方式,速度很快,但是程序不是很好写,因为这是一种侵入式的开发。 如果考虑并发控制,还要处理同步的问题,只要不是高性能场景,进程间通信通常不考虑共享内存的方式。

    远程调用

    内存共享不太好用,因此本地消息有两种常见的方法 远程调用 和 消息队列

    • 远程调用(Remote Procedure Call,RPC)是一种通过本地程序调用来封装远程服务请求的方法。
      在这里插入图片描述

    1.客户端调用函数(方法); 2. stub 将函数调用封装为请求; 3. 客户端 socket 发送请求,服务端 socket 接收请求; 4. 服务端 stub 处理请求,将请求还原为函数调用;5. 执行服务端方法; 6. 返回结果传给 stub; 7. stub 将返回结果封装为返回数据; 8. 服务端 socket 发送返回数据,客户端 socket 接收返回数据; 9. 客户端 socket 将数据传递给客户端 stub; 10. 客户端 stub 把返回数据转义成函数返回值。

    • 实战的时候 ,通常的做法是使用框架,RPC 调用的方式比较适合微服务环境的开发,当然 RPC 通常需要专业团队的框架以支持高并发、低延迟 的场景。
    • RPC 真正的缺陷是增加了系统间的耦合。当系统主动调用另一个系统的方法时,就意味着在增加 两个系统的耦合。长期增加 RPC 调用,会让系统的边界逐渐腐化

    消息队列

    • 事件不会增加耦合,如果一个系统订阅了另一个系统的事件,那么将来无论谁提供同类型的事件,自己都可以正常工作。系统依赖的不是另一个系统,而是某种事件。如果哪天另一个系统不存在了,只要事件由其他系统提供,系统仍然可以正常运转。
    • 实现事件可以用消息队列。 ( Domain Drive Design 这个方向的知识。)
    • 另一个用到消息队列的场景是纯粹大量数据的传输。 比如日志的传输,中间可能还会有收集、清洗、筛 选、监控的节点,这就构成了一个庞大的分布式计算网络。

    在这里插入图片描述

    进程线程管理

    计算密集型和 I/O 密集型

    • 计算,就是利用 CPU 处理算数运算。
    • 如果一个应用的主要开销在计算上, 我们称为计算密集型
    • I/O 本质是对设备的读写。读取键盘的输入是 I/O,读取磁盘(SSD)的数据是 I/O.
    • 如果操作对 I/O 的依 赖强,比如频繁的文件操作(写日志、读写数据库等),可以看作I/O 密集型
    • **读取硬盘数据到内存中这个过程,CPU 需不需要一个个字节处理 ? **通常是不用的,因为在今天的计算机中有一个叫作 Direct Memory Access(DMA)的模块,这个模块 允许硬件设备直接通过 DMA 写内存,而不需要通过 CPU(占用 CPU 资源)。
      在这里插入图片描述
    • 一般DMA区域是无法使用的,如果说你想把一个数组拷贝到另一个数组内,执行的 memcpy 函数内 部实现就是一个个 byte 拷贝,这种情况也是一种CPU 密集的操作。

    衡量 CPU 的工作情况的指标

    在这里插入图片描述
    CPU 忙碌有 3 种情况: 1.
    执行用户空间程序; 2. 执行内核空间程序; 3. 执行中断程序。
    CPU 空闲有 2 种情况。 1.CPU 无事可做,执行空闲指令(注意,不能让 CPU 停止工作,而是执行能耗更低的空闲指令)。 2. CPU 因为需要等待 I/O 而空闲,比如在等待磁盘回传数据的中断,这种我们称为 I/O Wait。

    • top指令 查看当前机器状态的快照
      在这里插入图片描述
    1. us(user),即用户空间 CPU 使用占比。
    2. sy(system),即内核空间 CPU 使用占比。
      3.ni(nice),nice 是 Unix 系操作系统控制进程优先级用的。-19 是最高优先级, 20 是最低优先 级。这里代表了调整过优先级的进程的 CPU 使用占比。
    3. id(idle),闲置的 CPU 占比。
      5.wa(I/O Wait),I/O Wait 闲置的 CPU 占比。
      6.hi(hardware interrupts),响应硬件中断 CPU 使用占比。
      7.si(software interrrupts),响应软件中断 CPU 使用占比。
      8.st(stolen),如果当前机器是虚拟机,这个指标代表了宿主偷走的 CPU 时间占比。对于一个宿主 多个虚拟机的情况,宿主可以偷走任何一台虚拟机的 CPU 时间。
    • 上面我们用 top 看的是一个平均情况,如果想看所有 CPU 的情况可以 top 之后,按一下1
      在这里插入图片描述

    负载指标

    • load average——平均负载. 如果这个值大于1,说明CPU 相当忙碌。因此如果你想发现问题,可以先检查这个指标。
      在这里插入图片描述
    • load average,你可以看/proc/loadavg文件。

    通信量(Traffic)

    在这里插入图片描述
    网络状况 : 查看/proc/net/dev
    Interface(网络接口),可以理解成网卡
    Receive:接收的数据
    Transmit:发送的数据
    byte 是字节数
    package 是封包数
    erros 是错误数
    drop 是主动丢弃的封包,比如说时间窗口超时了
    fifo: FIFO 缓冲区错误
    frame: 底层网络发生了帧错误,代表数据出错了

    衡量磁盘工作情况

    io问题查看命令 :iotop 没有的话 yum install iotop -y
    磁盘空间查看可以用df指令

    在这里插入图片描述

    决定进程/线程数量

    因此在实际工作中,开的线程、进程数往往是超过 CPU 核数的。具体需要 然后进行模拟真实线上压力的测试,分析压测的结果。
    在这里插入图片描述
    在这里插入图片描述
    在这里插入图片描述

    我的服务应该开多少个进程、多少个线程?

    • 【解析】 计算密集型一般接近核数,如果负载很高,建议留一个内核专门给操作系统。I/O 密集型一般 都会开大于核数的线程和进程。 但是无论哪种模型,都需要实地压测,以压测结果分析为准;另一方 面,还需要做好监控,观察服务在不同并发场景的情况,避免资源耗尽。
      然后具体语言的特性也要考虑,Node.js 每个进程内部实现了大量类似协程的执行单元,因此 Node.js 即便在 I/O 密集型场景下也可以考虑长期使用核数 -1 的进程模型。而 Java 是多线程模型,线程池通常 要大于核数才能充分利用 CPU 资源。
      所以核心就一句,眼见为实,上线前要进行压力测试。

    思考题

    进程的开销比线程大在了哪里?

    Linux 中创建一个进程自然会创建一个线程,也就是主线程。创建进程需要为进程划分出一块 完整的内存空间,有大量的初始化操作,比如要把内存分段(堆栈、正文区等)。创建线程则简单得 多,只需要确定 PC 指针和寄存器的值,并且给线程分配一个栈用于执行程序,同一个进程的多个线程 间可以复用堆栈。因此,创建进程比创建线程慢,而且进程的内存开销更大。

    fork问题

    • fork() fork() fork() print("Hello World\n") 请问这个程序执行后, 输出结果 Hello World 会被打印几次?print("Hello World\n") 请问这个程序执行后, 输出结果 Hello World 会被打印几次?
      八次
      在这里插入图片描述
      在这里插入图片描述
      在这里插入图片描述

    如何控制同一时间只有 2 个线程 运行?

    • 同时控制两个线程进入临界区
    • 一种方式可以考虑用信号量(semaphore)。
      另一种方式是考虑生产者、消费者模型。想要进入临界区的线程先在一个等待队列中等待,然后由消费者每次消费两个。这种实现方式,类似于实现一个线程池,所以也可以考虑实现一个 ThreadPool 类, 然后再实现一个调度器类,最后实现一个每次选择两个线程执行的调度算法。

    如果考虑到 CPU 缓存的存在,会对上面我们讨论的算法有什么影响?

    在这里插入图片描述

    在同一时刻,多线程之间,对内存中某个地址的数据认知是否一致(简单理解,就是多个线程读取同一个内存地址能不能读到一致的值)。
    对某个地址,和任意时刻,如果所有线程读取值,得到的结果都一样,是一种强一致性,我们称为线性 一致性(Sequencial Consistency),含义就是所有线程对这个地址中数据的历史达成了一致,历史没 有分差,有一条大家都能认可的主线,因此称为线性一致。 如果只有部分时刻所有线程的理解是一致 的,那么称为弱一致性(Weak Consistency)。

    int lock = 0
    void lock() {
     while(!cas(&lock, 0, 1)){ 
     // CPU降低功耗的指令
    	} 
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6

    上述程序构成了一个简单的自旋锁(spin-lock)。如果考虑到内存一致性模型,线程 1 通过 cas 操作将 lock 从 0 置 1。这个操作会先发生在线程所在 CPU 的 L1 缓存中。cas 函数通过底层 CPU 指令保证了原 子性,cas 执行完成之前,线程 2 的 cas 无法执行。当线程 1 开始临界区的时候,假设这个时候线程 2 开始执行,尝试获取锁。如果这个过程切换非常短暂,线程 2 可能会从内存中读取 lock 的值(而这个值 可能还没有写入,还在 Thread 所在 CPU 的 L1、L2 中),线程 2 可能也会通过 cas 拿到锁。两个线程 同时进入了临界区,造成竞争条件。这个时候,就需要强制让线程 2的读取指令一定等到写入指令彻底完成之后再执行,避免使用 CPU 缓存。

    除了上锁还有哪些并发控制方法?

    【解析】 基于乐观锁的版本控制、区块链技术。 (非OS ) 处理并发还可以考虑 Lock-Free数据结构。比如 Lock-Free 队列,是基于 cas 指令实现的,允许多个线程使用这个队列。再比如 ThreadLocal,让每个线程访问不同的资源,旨在用空间换时间,也是避免锁的一种方案。

    举例各 2 个悲观锁和乐观锁的应用场景?

    • 乐观锁、悲观锁都能够实现避免竞争条件,实现数据的一致性。 比如减少库存的操作,无论是 乐观锁、还是悲观锁都能保证最后库存算对(一致性)。 但是对于并发减库存的各方来说,体验是不一 样的。悲观锁要求各方排队等待。 乐观锁,希望各方先各自进步。所以进步耗时较长,合并耗时较短的 应用,比较适合乐观锁。 比如协同创作(写文章、视频编辑、写程序等),协同编辑(比如共同点餐、 修改购物车、共同编辑商品、分布式配置管理等),非常适合乐观锁,因为这些操作需要较长的时间进 步(比如写文章要思考、配置管理可能会连续修改多个配置)。乐观锁可以让多个协同方不急于合并自 己的版本,可以先 focus 在进步上。
    • 相反,悲观锁适用在进步耗时较短的场景,比如锁库存刚好是进步(一次库存计算)耗时少的场景。这种场景使用乐观锁,不但没有足够的收益,同时还会导致各个等待方(线程、客户端等)频繁读取库存 ——而且还会面临缓存一致性的问题(类比内存一致性问题)。这种进步耗时短,频繁同步的场景,可 以考虑用悲观锁。类似的还有银行的交易,订单修改状态等。
      再比如抢购逻辑,就不适合乐观锁。抢购逻辑使用乐观锁会导致大量线程频繁读取缓存确认版本(类似 cas 自旋锁),这种情况下,不如用队列(悲观锁实现)。

    线程调度都有哪些方法?

    解析: 非抢占的先到先服务的模型是最朴素的,公平性和吞吐量可以保证。但是因为希望减少用户的平均等待时间,操作系统往往需要实现抢占。操作系统实现抢占,仍然希望有优先级,希望有最短任务优先。但是这里有个困难,操作系统无法预判每个任务的预估执行时间,就需要使用分级队列。最高优先级的任务可以考虑非抢占的优先级队列。 其他任务放到分级队列模型中执行,从最高优先级时间片段最小向 最低优先级时间片段最大逐渐沉淀。这样就同时保证了小任务先行和高优任务最先执行。

    什么情况下会触发饥饿和死锁? (“什么情况下”的时候,面试官实际想听的是你经过理解,整理 得到的认知。回答应该是概括的、简要的)

    【解析】 线程需要资源没有拿到,无法进行下一步,就是饥饿。死锁(Deadlock)和活锁(Livelock) 都是饥饿的一种形式。 非抢占的系统中,互斥的资源获取,形成循环依赖就会产生死锁。死锁发生后, 如果利用抢占解决,导致资源频繁被转让,有一定概率触发活锁。死锁、活锁,都可以通过设计并发控 制算法解决,比如哲学家就餐问题。

    如果哲学家就餐问题拿起叉子、放下叉子,只需要微小的时间,主要时间开销集中在 think 需要计算资源(CPU 资源)上,那么使用什么模型比较合适?

    哲学家就餐问题最多允许两组哲学家就餐,如果开销集中在计算上,那么只要同时有两组哲学 家可以进入临界区即可。不考虑 I/O 成本,问题就很简化了,也失去了讨论的意义。比如简单要求哲学 家们同时拿起左右手的叉子的做法就可以达到 2 组哲学家同时进餐。

    进程间通信都有哪些方法?

    • 如果考虑单机模型,有管道、内存共享、消息队列。这三个模型中,内存共享程序最难写,但是性 能最高。管道程序最好写,有标准接口。消息队列程序也比较好写,比如用发布/订阅模式实现具 体的程序。
    • 如果考虑分布式模型,就有远程调用、消息队列和网络请求。直接发送网络请求程序不好写,不如 直接用实现好的 RPC 调用框架。RPC 框架会增加系统的耦合,可以考虑 消息队列,以及发布订阅 事件的模式,这样可以减少系统间的耦合。

    还有哪些我没有讲到的进程间通信方法?

    • 使用数据库
    • 使用普通文件
    • 信号,一个进程可以通过操作系统提供的信号。举个例子,假如想给某个进程 (pid=9999)发送一个 USR1 信号,那么可以用:kill -s USR1 9999 进程 9999 可以通过写程序接收这个信号。 上述过程除了用kill指令外,还可以调用操作系统 API 完 成。

    如果磁盘坏了,通常会是怎样的情况?

    • 磁盘如果彻底坏了,服务器可能执行程序报错,无法写入,甚至死机。这些情况非常容易发 现。
    • sudo badblocks -v /dev/sda5 检查坏道
    • df命令查看自己的文件系统
      在这里插入图片描述
  • 相关阅读:
    Python numba 的使用
    零基础入门Python基础知识全面梳理!从零开始成为编程高手
    PCL点云处理之特征计算、特征点对匹配、配准矩阵计算,结果保存的全流程 (二百二十)
    【VIM】VIm-plug插件
    [附源码]java毕业设计学生宿舍管理系统
    Java—类加载机制
    RabbitMQ入门基础篇
    2023老博会|山东老龄产业展|老年用品展|适老度假与旅游展
    视觉在自动泊车系统中的设计与实现和挑战综述
    创业了...
  • 原文地址:https://blog.csdn.net/weixin_49486457/article/details/126373402