操作系统的一些问题

一、进程、线程、协程

1.1 进程、线程、协程在OS层是如何实现的?

进程

进程是操作系统分配资源的基本单位。

进程实质上就是一些数据的集合,这些数据包括代码,输入,输出,堆空间,栈空间,权限,pcb等等。

其实在内核看来进程对应的就是一个task_struct组成的结构体。

这个结构体保存着对应这个进程所拥有的资源的数据结构指针。

持有的信息:

  • pid,唯一标识
  • uid,用户标识符
  • 特征信息:系统进程、用户进程等
  • 进程状态
  • 进程组信息
  • 时间片信息
  • 优先级
  • 通信信息
  • 现场保护区
  • 资源清单(程序段指针、数据段指针、IO设备等)
  • 进程实体信息,程序所在路径、进程数据
  • 其他信息
线程

线程是操作系统调度的基本单位(内核级线程)。

  • 用户线程:需要用户自己定义数据结构、维护线程的生命周期以及调度,即需要自己实现一个类似于OS进程调度的管理程序。内核对用户线程无感知。运行于用户态。(因此调度算法都需要自己设计实现,比如go协程的GPM模型)
  • 内核线程:由操作系统内核管理的线程,即整个生命周期以及调度由OS内核掌握。

在这里插入图片描述

此处引出一个问题,Java创建的线程到底是属于内核线程呢?还是用户线程?

Java创建的线程应该是不确定的,因为虚拟机并不会帮我们管理线程的生命周期以及调度,而是通过本地方法直接调用了操作系统的系统调用。所以,具体创建的是什么线程,那就需要看底层操作系统提供的API创建的线程的类型了。如果OS帮我们在内核的进程调度之上封装提供了一层用户级线程的线程库,我们使用这个库的api创建线程,那么就是用户线程。如果直接使用内核提供的api调用创建可以由CPU感知的线程,那么就是内核线程。(我理解的windows下应该是内核线程,linux不懂,但大概也是内核级的???)

引出问题之二,线程是怎么切换的呢?

这个问题我也不是很清晰,只能大致说一下我的理解。

如果是时间片轮转算法,当一个线程执行一定时间后,时钟发起一个硬中断,CPU收到信号后,应该会停止当前的执行流程,同时应该改变了某些标志啥的,然后会转去执行对应的时钟中断指令,此时应该是没有发生线程切换的,即tcb没有换,只是相当于当前线程的执行流变化了。然后时钟中断指令开始执行之前的一段逻辑应该就是保存当前线程的用户栈和内核栈,然后执行时钟中断指令,此时应该是相当于没有tcb的,虽然tcb没换,但是这个线程名存实亡,然后执行调度算法,选一个新的tcb以及它所需要的运行环境,将这些数据替换原来在寄存器中的数据,将旧的tcb换成新的tcb。这个新的tcb里面关联着这个新的线程的用户栈和内存栈数据的地址,然后将寄存器啊,物理内存的对应地址放上内核栈、用户栈的数据,然后中断返回,就开始执行新的线程了。这个过程在cpu看来就是一个串行的过程

我的感觉是这些时候,其实是没必要纠结哪个线程在执行这些操作的,线程只是个逻辑概念,方便我们去理解计算机提高并发利用率的一个概念,没必要处处都要与某个线程强绑定。当然涉及到上层业务逻辑的时候,为了保证业务逻辑的正确性,还是要好好区分的。

协程

用户级线程。
以go语言的协程为例,go语言的协程采用的GPM模型。

  • G:G表示go语言中的协程(goroutine),即受管理的轻量级用户级线程,G的创建、休眠、阻塞、唤醒、停止都受go运行时的管理。
  • M:machine,因为用户级线程不能被OS感知,终究是用户模拟的,想运行起来还是需要对应一个真正的内核线程的,M就是系统级线程。M会从运行队列中取出G, 然后运行G, 如果G运行完毕或者进入休眠状态, 则从运行队列中取出下一个G运行, 周而复始。有时候G需要调用一些无法避免阻塞的原生代码, 这时M会释放持有的P并进入阻塞状态, 其他M会取得这个P并继续运行队列中的G.
  • P: 表示M运行G所需要的资源,相当于调度者

在这里插入图片描述
每个M必须持有一个P才能执行G,G受P的调度。

同时如果一个M执行某个G因为系统调用而阻塞,这个P会自动寻找一个新的M来执行,从而把活跃状态的M保持在一定数量。
在这里插入图片描述
当M0返回时,它必须尝试获取一个P来继续执行进行了系统调用的那个G,如果没得获取到,那么这个M就会将G放入一个全局的队列中,自己进入休眠状态

对比

在这里插入图片描述

windows和linux中的线程

在这里插入图片描述
图片描述的不一定对,因为后面linux内核有了pthread线程库。

1.2 内核态与用户态

基础概念

内核态与用户态是操作系统运行的两个级别,若线程处于用户态,则可访问的资源很受限,处于内核态,则可以访问所有的资源。

按照我的理解就是任何一个被OS感知的线程都会有一个标志位,这个标志位打开,那么就处于内核态,否则就是用户态,而我们用户通过OS提供的API创建出来的线程,这个标志位默认是关闭的,所以无法轻易访问内核中的资源,保护了操作系统不至于被我们的程序搞崩。而由OS在启动时创建的一些管理内核资源的线程则标志位默认是打开的。

而我们想调用系统资源,使用系统调用的时候,陷入内核态的过程我的理解就是设置标志位的过程。

所以,内核线程和用户线程主要看是否由OS管理,和运行于什么态是无关的,内核线程可以运行于用户态也可以运行于内核态。

但是需要注意,线程由用户态陷入到内核态的时候,会发生线程栈的转换,即保存用户栈的内容,切换为内核栈,但是线程没有切换,占用cpu的资源的线程的tcb没有发生更换。(任何一个由OS感知的内核线程都拥有两个栈,一个在用户态使用的用户栈,一个是在内核态使用的内核栈),这样只是涉及到一个线程一部分线程的上下文的保存,如果发生线程切换,则需要保存线程全部的上下文。

划分内核态与用户态的作用是什么?

为了防止某些安全性不高的程序乱用资源把整个系统搞崩。例如清内存、设置时钟等等。

1.3 僵尸进程、孤儿进程、守护进程

  • 孤儿进程:父进程已经结束了,pcb被回收了,但是这个父进程所属的子进程还在执行,最后这些进程其实会被linux的初始进程init进程“收养”,最后执行完毕以后,由init进程调用wait()方法回收资源。
  • 僵尸进程:子进程执行完毕了,但父进程没有调用wait()或者waitpid()去回收子进程的资源,所以子进程的pcb还存留于OS中,这些进程就是僵尸进程。危害是导致pid得不到回收,导致pid不够用。可以通过杀死父进程、在父进程调用wait、使用信号机制等方式解决。
  • 守护进程:守护进程就是通常讲Daemon进程,是linux后台执行的一种服务进程,特点是独立于控制终端、周期性地执行某种任务或等待处理某些发生事件,不会随终端关闭而停止,直到接受停止信息才会结束,且一般采用以d结尾的名字。

1.4 程序是如何启动?

在这里插入图片描述
exe程序具体过程:

  1. 编译,连接
  2. 执行启动命令
  3. 当前进程调用fork系统调用,产生一个子进程(task_struct)
  4. 子进程执行exec()函数,填充task_struct结构体
  5. 根据exec()函数传入的文件名参数,读取该文件的内容
  6. 初始化当前进程所分配的用户空间内存
  7. 进行程序运行的准备
  8. 最终回到用户态,执行指令。

java程序的运行过程:

二、进程同步与通信

2.1 进程通信的方式

2.1.1 低级通信方式(同步与互斥)
  • 信号量
2.1.2 高级通信方式
  • 共享内存:共享内存允许两个或多个进程共享一个给定的存储区,这一段存储区可以被两个或两个以上的进程映射至自身的地址空间中,一个进程写入共享内存的信息,可以被其他使用这个共享内存的进程,通过一个简单的内存读取,从而实现了进程间的通信。是最快的进程通信方式,因为不需要像管道文件一样,需要进行用户态到内核态的切换。
  • 消息机制:消息队列,就是一个消息的链表,保存在内核中。用户进程可以向消息队列添加消息,也可以向消息队列读取消息。消息队列与管道通信相比,其优势是可以对每个消息指定特定的消息类型,接收的时候不需要按照队列次序,而是可以根据自定义条件接收特定类型的消息。可以把消息看做一个记录,具有特定的格式以及特定的优先级。对消息队列有写权限的进程可以向消息队列中按照一定的规则添加新消息,对消息队列有读权限的进程可以从消息队列中读取消息。
  • 管道:半双工通信,存在于内存中,类似于一个内核缓冲区,实质上一种特殊的文件,通信双方相当于对一个文件进行读写。需要进行用户态到内核态的切换。
  • 套接字

2.2 OS是如何实现的?

三、内存管理

3.1 虚拟内存与物理内存

  • 请求分页
  • 请求分段
  • 请求段页式

需要页表存储虚拟地址与物理地址的映射,还需要一个地址转换器对逻辑地址和物理地址进行变换。
同时需要缺页中断,每个页表项所表示的页如果在磁盘上则需要存储其对应的磁盘物理地址,使用时通过缺页中断将其从磁盘替换到内存中。

3.2 内存映射&零拷贝

传统IO

传统IO涉及到两次拷贝,一次是从内核空间拷贝到用户空间,然后由我们应用程序操作将其再拷贝到内核中,由对应的程序去处理。需要陷入内核态两次,资源消耗较大。

内核空间只有线程处于内核态才能访问。

在这里插入图片描述
具体过程:

  1. read()调用导致上下文从用户态切换到内核态。内核通过sys_read()(或等价的方法)从文件读取数据。DMA引擎执行第一次拷贝:从文件读取数据并存储到内核空间的缓冲区。
  2. 请求的数据从内核的读缓冲区拷贝到用户缓冲区,然后read()方法返回。read()方法返回导致上下文从内核态切换到用户态。现在待读取的数据已经存储在用户空间内的缓冲区。至此,完成了一次IO的读取过程。
  3. send()调用导致上下文从用户态切换到内核态。第三次拷贝数据从用户空间重新拷贝到内核空间缓冲区。但是,这一次,数据被写入一个不同的缓冲区,一个与目标套接字相关联的缓冲区。
  4. send()系统调用返回导致第四次上下文切换。当DMA引擎将数据从内核缓冲区传输到协议引擎缓冲区时,第四次拷贝是独立且异步的

在这里插入图片描述

内存映射

内存映射是我们将一段用户空间映射到内核空间,这样我们在用户态进行修改,内核空间同步变化,同样内核空间的变化,也会反应到用户空间,这样就不需要在用户空间和内核空间进行来回的数据拷贝了。

我的理解就是用户态的一块逻辑地址和内核的逻辑地址对应同一块物理内存,即我们用户态的逻辑地址和内核空间的逻辑地址经过计算后得到的物理地址是一样的,对应一块应该由OS管控的内存(内核空间)。

具体步骤:

  1. read()调用导致上下文从用户态切换到内核态。内核通过sys_read()(或等价的方法)从文件读取数据。DMA引擎执行第一次拷贝:从文件读取数据并存储到内核空间的缓冲区。
  2. 请求的数据到达内核的读缓冲区,然后read()方法返回。read()方法返回导致上下文从内核态切换到用户态。
  3. 在用户态调用send()导致上下文从用户态切换到内核态。数据从内核空间的缓冲区拷贝到目标套接字相关联的缓冲区。
  4. send()系统调用返回导致第四次上下文切换。当DMA引擎将数据从内核缓冲区传输到协议引擎缓冲区时,这次拷贝是独立且异步的。

由内核–>用户态—>内核态的拷贝,变为内核A区—>内核B区的拷贝。
但此时仍然会有两次陷入内核态的过程,只是两次拷贝过程。

在这里插入图片描述

零拷贝

零拷贝则是将数据拷贝到内核缓冲区后,不再回到用户态了,相当于由原来的read+write两个系统调用变成了一个完整的过程,这样的话就没必要从内核态切换到用户态一次了,直接在内核态将数据从内核缓冲区拷贝到Socket缓冲区。

这样,虽然数据拷贝次数还是3次,但是用户态和内核态的切换次数减少了两次。

在这里插入图片描述

实际上,内核的拷贝次数还可以更少,比如直接从内核缓冲区不拷贝到Socket缓冲区,直接拷贝到网络协议栈。

Java的直接内存(DirectBuffer)

首先明确非直接内存与直接内存的定义。

  • 非直接内存:JVM管理的内存
  • 直接内存:堆外内存,处于JVM的管理之外的用户空间中的内存

Java在进行数据读取的时候会比前面提到的IO还多一次的拷贝,即数据拷贝到用户空间的缓冲区以后再由JVM拷贝到具体的堆空间中,因为堆内存是由JVM管理的,而堆内存中的地址是需要经常变动的,因为JVM会存在GC,如果我们读取数据的时候直接确定好堆内的地址,那么就给GC带来了额外的复杂性。

因为GC整理内存空间时要额外保留我们读取文件时预留的堆内存空间(GC会导致存活对象的地址发生变动)。所以,直接先将数据放在堆外内存中,然后再由堆外内存拷贝到堆内存中。这个地方由堆外拷贝到堆内的时候依然可能会发生GC,我的理解是文件读取的过程是比较慢的,我们需要在陷入到内核态的时候就确定地址,这样GC的可能性太大了,而数据已经存在于用户态空间了,拷贝是很快的,JVM可能做了一些小小的保证。

数据流动:磁盘---->内核缓冲区—>用户缓冲区(native内存)—>JVM堆内存—>用户缓冲区—>内核缓冲区—>Socket缓冲区—>协议栈

在这里插入图片描述

使用直接内存,可以省去从用户态拷贝到堆内存的过程。

在这里插入图片描述
这个图我觉得该打×的地方在用户空间应用程序这个地方。我理解的用户空间是指JVM进程,应用程序是我们用java起的main线程。所以,直接内存应该是减少了native内存到jvm堆内存的数据拷贝。因为我们在堆内存持有的是直接内存的引用,其指向堆外内存,相当于我们在堆内直接操作堆外内存,而不需要将这部分数据拷贝到堆内 (和OS的直接内存映射效果看起来差不多,一个是内核态映射到用户态,一个是堆外内存映射到堆内)

然后如果堆外内存使用了OS的内存映射技术,那么native内存再映射到内核,那么就不需要进行内核到用户态的拷贝了。

PS: 因为虚拟机规范并没有规定堆外内存的具体要求,有的虚拟机实现是直接申请的内核空间的内存,这样的话相当于省去了从内核拷贝到用户空间的过程,同时也省去了从堆外拷贝到堆内的过程。

Java的零拷贝实现原理就是利用了OS提供的零拷贝,所以不再说了。

3.3 内存对齐
  • 内存对齐的作用是减少内存的访问次数

四、IO

4.1 IO控制方式有哪些

这部分功能主要由内核或者设备驱动实现,我们写的程序调用封装好暴露给我们的接口。

  • 程序控制方式:由程序控制CPU和设备之间的信息传送,程序不断通过IO指令询问/检测设备是否忙,如果空闲才能使用IO设备传递一个字符,否则程序忙等。由于每次IO指令都是由CPU执行,所以CPU和IO设备是串行的。
  • 中断驱动控制:程序检测到IO设备忙,那么自己就阻塞让出CPU,然后CPU去干别的事情,IO设备空闲了发出一个中断信号,CPU响应中断,唤醒进程去执行IO指令,此时CPU每次只负责执行IO指令,不需要轮询IO设备的状态,而是由IO设备主动告知CPU。并发度提高了一下,但是IO指令执行还是依靠CPU来负责,所以并发度还不是特别高。
  • DMA(直接存储器访问):CPU直接当甩手掌柜,将要传输的数据什么的全部交给另外一个硬件去负责,这个硬件就是DMA控制器,只有在数据传输完毕之后,DMA控制器才会发出一个中断通知CPU数据全部传输完毕了。但是这个硬件呢,有点笨笨的,智商不高,只能和一种设备打交道。
  • 通道控制:这种方式相当于请了一个高级经理,这个经理比DMA聪明很多,掌握更抽象的指令,可以和多种设备交互。但是原理大差不差,区别只是可以用一种硬件搞定多种设备的IO了。

具体的实现原理我也不是很清楚,对OS底层了解比较少,以后了解了再补充。

4.2 IO模型(BIO\NIO\AIO)

select\poll\epoll

selector、epoll详解

reactor\parator

五、CPU

5.1 内存屏障

java并发编程(4)-----内存模型

volatile在os层面上是如何实现的?

Volatile关键字

六、参考

Linux下调用pthread库创建的线程是属于用户级线程还是内核级线程?
https://zhuanlan.zhihu.com/p/212268670

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值