OS-进程与线程

337 阅读21分钟

进程的通信

①管道模型:管道实际上是一段缓存,它是一种单向传输数据的机制,里面的数据只能从一端写入,从一端读出,内核以循环队列的方式把进程发送给其他进程的数据临时存入这段缓存中,其他进程只能从这段缓存中读取数据。管道有两种类型:

  • 匿名管道:在执行命令时创建,在通信的两个进程退出后会自动释放。
  • 命名管道:命名管道的名称保存在磁盘上,内容依旧是以缓存的形式保存在内核中,在通信的两个进程退出后,内核中的缓存数据丢失但磁盘上的命名管道依旧存在。

②信号:信号可以在任何时候发送给某一进程,然后进程会自动执行该信号对应的信号处理函数,用于应急举措。

③消息队列:消息队列是保存在内核中的消息链表,消息队列会把数据分成一个个固定大小的独立数据单元,消息的发送方和接收方需要约定好数据单元的数据类型,发送方从用户态把消息体写入内核态的消息队列,消息体会在消息队列中暂存,接收方在需要数据时就从内核态取出数据写回用户态。

④共享内存:如果两个进程通信特别紧密,而且要共享一些比较大的数据,使用管道或消息队列就不合适了,共享内存解决了这一问题。多个进程都拿出他们的一块虚拟地址空间,通过页表映射到相同的物理内存中,进程访问时就会把这个共享内存加载到自己的虚拟空间中,实现进程通信。

但是正因如此,如果两个进程同时读写共享的数据,就会发生进程操作被覆盖的情况。所以我们需要互斥的临界区来避免这一问题,临界区是对共享内存进行访问的临界片段。保证了进程独占临界区,就保证进程间的通信。

我们的解决办法首先是忙等待。忙等待就是临界区中有进程时,当前进程就会原地等待,相当于加锁。但这样浪费了CPU利用率,而且会有一种情况,因为进程是原地等待并非睡眠的,所以如果等待的进程优先级高,那么它会占用CPU,导致另一个进程无法出临界区,当前进程永远忙等待。

所以我们可以使用sleep与weekup对进程进行阻塞。但对于唤醒时机,我们需要一个变量去实现,也就是互斥量。我们对互斥量进行加锁释放锁,来实现对进程访问的互斥。但仅仅是用互斥量,会出现并发问题。(比如缓存区为空,消费者读取到count为0;此时消费者暂停 生产者启动并读取到count为0,所以它会向缓存区中加入数据并向消费者发送wakeup。但消费者此时并没有sleep,所以wakeup丢失。那CPU再次轮转调度到消费者时,它先前读到count为0了嘛,然后wakeup也丢失了,所以它会永远睡眠。生产者不断push直至缓冲区满,也会睡眠)

(应用场景RE) 所以我们换了一个思路,把wakeup次数记录下来,也就是信号量,当信号量大于等于0时表示可用资源数,当信号量小于零时表示等待该资源的进程数。

对信号量的操作有两种,分别是P获得一个资源和V释放一个资源。进程在请求资源时先执行P操作使得信号量-1,若此时信号量仍大于等于0,则进程成功获取资源;进程占用资源结束时会执行V操作使得信号量+1,若此时信号量小于0,则说明有进程正在等待该资源,于是去等待队列中唤醒一个进程,然后进行CPU调度,从而实现进程间的互斥与同步。

⑤socket:

上面说的进程间通信同是在同一个Linux上的两进程之间的通信。如果需要跨主机进行进程通信,就需要socket通信。socket通信是基于TCP/IP网络协议的,但它不属于网络分层中的任意一层,它是属于操作系统的概念。对于网络分层模型,从物理层到传输层都是在Linux内核态中,应用层是在用户态中,而用户态应用层和内核态的交互机制,就是通过socket系统调用!

一个socket监听主机上的一个端口。应用程序发送的数据包会通过socket发送给内核,内核会对数据包进行层层封装,从物理网口发出。然后经过交换机、路由器等到达对端网络。数据包在对端网络中层层解封装,内核根据TCP头部中的端口号,通过socket接口最终发送给对端的应用程序。 进程通信的应用场景

①匿名管道:父子进程、兄弟进程间的通信。 ②命名管道:与匿名管道的区别是可以使得两个互不相关的进程通信。 ③信号:用于针对异常情况下的进程通信,比如线上系统故障 ④消息队列:异步,相对进程独立。

【线程的通信】:在并发编程艺术中。

上下文切换

CPU上下文切换

下面的系统调用、上下文切换,都需要CPU上下文切换

Linux是个多任务操作系统,它的多任务并不是指多任务同时运行,而是系统在很短的时间内,将CPU轮流分配给它们,造成了多任务同时运行的错觉。而多个任务轮转运行,CPU就需要一个内存空间来标记它们执行的位置,也就是CPU寄存器和程序计数器了。CPU寄存器是CPU内置存储指令的极速内存,程序计数器是存储CPU正在执行的指令或下一个指令位置,它们都是CPU运行任务时的必须依赖环境,所以CPU寄存器和程序计数器中的位置记录被称作为CPU上下文。

那么,CPU上下文切换就是指,先把前一个任务的CPU上下文临时保存在内核中,然后加载新任务的CPU上下文到这些寄存器和程序计数器中,然后跳转到程序计数器所指向的新位置,运行新任务。

而任务是什么呢?是进程,是线程,是中断信息(中断处理程序的调用)即三种调度。而进程又分为单个进的程特权模式切换和两个进程在内核态之间的切换,即系统调用与上下文切换。

系统调用

内核态和用户态:计算机有内核态和用户态两种运行模式。软件中的操作系统运行在虚拟内存的内核空间,它具有所有硬件的完全访问权,可以执行机器能够运行的任何指令。软件的其余部分运行在虚拟内存的用户空间,用户态不可以直接访问内存等硬件设备,必须通过系统调用陷入到内核中,才能访问这些特权资源。也就是说,在用户空间运行的进程,称为进程的用户态;在内核空间运行的进程,称为进程的内核态。

而进程从用户态到内核态的特权转变,就需要通过系统调用完成。

系统调用过程中,CPU寄存器更新为内核态的指令位置,进程从用户态陷入到内核态;系统调用结束后,CPU寄存器恢复为原来用户态的指令位置,进程切换会用户态。所以一次系统调用,发生了两次CPU上下文切换。但系统调用并不涉及虚拟内存等用户态资源,也不会切换进程,系统调用过程是一直在同一个进程中运行的。(系统调用 == 两次CPU上下文切换)

进程上下文切换

进程是由内核来管理和调度的,进程的切换只能发生在内核态。所以发生进程上下文切换时,我们要临时保存进程上下文中的虚拟内存、栈、全局变量等用户态资源,和内核堆栈、寄存器等内核态资源。然后发生CPU上下文切换,加载了下一个进程的CPU上下文后,还需要刷新进程的虚拟内存、栈等用户态资源。

(进程上下文切换 == CPU上下文切换 + 用户态资源切换)

线程上下文切换

内核中的任务调度,调度对象实际上是线程;而进程只是给线程提供了虚拟内存、全局变量等资源。所以对于同一个进程中,线程对进程中的资源共享。也就是说:

两个线程属于同个进程时,虚拟内存、全局变量等资源共享。所以只需要切换线程的私有数据、寄存器等

两个线程属于不同进程时,资源不共享,线程切换就等于进程切换,即发送CPU上下文切换。
中断上下文切换

为了快速响应硬件的事件,中断处理会打断进程的正常调度和运行,转而调用中断处理程序,响应设备事件。所以在打断其他进程之前,就需要把此进程的状态保存下来,以便恢复。 中断上下文切换只设计内核态,即只需要保持CPU寄存器、内核堆栈等。

进程与线程的区别

〇进程代表应用程序啊。存放在磁盘上的二进制代码文件、要处理的输入输出文件等就是我们常说的应用程序。应用程序被执行时,操作系统会把数据加载到内存中,然后通过寄存器和内存堆栈等JVM内存结构来配合程序运行。也就是说,一个应用程序一旦执行,它就从磁盘上的二进制文件,变成了计算机内存中的数据、寄存器中的值、堆栈中的指令、被打开的文件等等。那么一个应用程序运行起来后的计算机执行环境综合,就可以被称作为进程。

  • 当进程只有一个线程时,进程就等于线程。
  • 当进程有多个线程时,这些线程会共享进程的虚拟内存、全局变量等资源,私有它们自己的栈、寄存器等私有资源。 ①线程是一个轻量级的进程,内核中的任务调度,调度对象实际上是线程,也就是说线程是进程调度与计算的基本单位,而进程是拥有资源的基本单位。CPU的一个个时间片实际上是分配给线程使用;而进程只是给线程提供了虚拟内存、全局变量等资源。

②线程比进程更轻量级,是因为创建线程的消耗要比进程少得多;多线程是共享进程的内存空间的,所以线程间的上下文切换、线程间的通信会更加简单。

③进程有各自的独立存储空间,每启动一个进程,系统就会为他分配地址空间。因为空间独立,所以一个进程的销毁并不会影响其他进程。而线程共享进程中的数据,使用相同的地址空间,所以线程没有独立的地址空间。

其他


线程状态

image.png

初始状态:线程已被创建,但还不允许被分配CPU执行

可运行状态:线程被成功创建,可以分配CPU执行

运行状态:线程得到操作系统分配的CPU,线程成功执行。

休眠状态:运行状态的线程调用阻塞API或者等待某个事件,放弃CPU的使用权。当等待的事件发生时,线程就进入可运行状态(Ojbect.wait()、Thread.join()、LockSupport.park()、锁)

终止状态:线程执行完或出现异常,线程生命周期结束。

Java线程状态

image.png

初始状态:

线程刚刚被创建,处于初始状态。

运行状态:

初始状态的线程执行start()方法,就进入了运行状态。

阻塞状态:

运行状态的线程在遇到synchronized锁时,若竞争失败则会进入阻塞状态;阻塞状态的线程成功获取锁后会进入运行状态。但线程调用阻塞式API,实际上是操作系统层面的线程阻塞,Java线程并不会阻塞。因为在JVM看来,等待CPU的使用权和等待IO是没有区别的,都是等待一个资源,都处于运行状态。

等待状态:

运行状态的线程调用Object.wait()、ThreadA.join()、LockSupport.park()时,线程进入无时限等待状态。 终止状态: 运行状态的线程的run()方法执行完成时、线程执行run()过程中抛出异常时,线程会进入终止状态。

等待状态的线程调用Thread.interrupt()方法时,线程会进入运行状态;运行状态的线程调用Thread.interrupt()方法时,会立即返回.

进程调度

Linux为每个CPU都维护了一个就绪队列,Linux会根据进程的优先级和等待CPU的时间进行排序,然后选择最需要CPU的进程,运行。进程调度引发进程上下文切换。 进程调度算法

批处理系统:处理不需要用户交互的周期性作业,比如薪水、账目、索赔等流程

  • 先来先服务:每个进程按照它们请求CPU的顺序来使用CPU。
  • 最短作业优先:调度程序选择执行时间最短的进程使用CPU。
  • 最短剩余时间优先:调度程序选择剩余运行时间最短的进程使用CPU。

交互式系统:通用的、与用户交互的操作系统。

  • ①轮转调度:以先来先服务的顺序为每个进程分配一个时间片,进程在该时间段内运行。若进程在时间片结束了,则会立即释放CPU并移除队列;若时间片结束时进程还在运行,则CPU会立即被剥夺,然后把进程放入队列尾部。时间片应设置适中,过短会导致太多的进程切换,降低CPU利用率;过长会短的交互任务的响应时间边长。
  • ②优先级调度:为进程设置优先级并分成若干类,优先为优先级类别高的进程使用CPU,在同一类别中的所有进程采用轮转调度使用CPU。进程得到时间片后实际上是分配给了对应线程,所以线程的优先级就决定了线程需要多或少地使用一些处理器资源。在Java线程中可以通过一个整型成员变量priority来控制优先级。设置优先级时,针对频繁阻塞的线程(休眠或I/O操作)要设置较高的优先级,针对需要较多CPU事件的线程要设置较低的优先级,确保处理器不会被独占。
  • ③多级队列:按照CPU密集程度来分优先级,然后为同类的同CPU密集程度的进程分配合理的时间片。
  • ④最短进程优先:根据进程的过去行为估计出运行时间最短的进程,让他使用CPU。

进程调度发生时机

①进程的CPU时间片耗尽,就会被系统挂起,调度到其他正在等待CPU的进程运行

②进程在系统资源不足时,会被挂起,系统就会调度其他进程运行。

③进程主动调用sleep()将自己挂起,发生进程调度。

④高优先级进程到来时,会发送进程调度。

孤儿进程和僵尸进程

孤儿进程:

一个父进程退出,而它的一个或多个子进程还在运行,那这些子进程称为孤儿进程。孤儿进程将被init进程所收养,由init进程完成它们的状态收集工作。

僵尸进程:

  • 一个进程使用fork异步创建子进程。如果子进程退出,而父进程并没有调用wait()系统调用取得子进程的终止状态,那么子进程的进程描述符仍然保持在系统中。那这个子进程称为僵尸进程。
  • 维护:僵尸进程会占用进程号,而系统的进程号是有限的,所以可能导致系统不能产生新的进程。
  • 解决:fork两次,将子进程置为孤儿进程,由init进程处理。

守护线程:为用户线程提供服务的线程,比如GC线程。它不依赖于应用,而是依赖于JVM,随着JVM的退出而关闭。Thread.setDaemon(true)设置为守护线程。

如果一个线程死循环了线程调度器如何处理

操作系统都是采用抢占式线程调度,即线程的CPU时间片使用完成后,就会让出CPU进行睡眠。所以线程死循环到CPU时间片用完,线程调度器就会为其他线程分配时间片并执行

Thread方法

getName()、currentThread()、sleep()、run()、start()

Thread.join():A线程中执行B.join()表示线程A需要等待线程B终止后才能从B.join()返回。当B线程终止时,会调用它自身的notifyAll()方法,通知所有等待在该线程对象上的线程唤醒。

Thread.field():让当前线程由“运行状态”进入“等待状态”,然后再和其他相同优先级或更高优先级的等待线程共同争抢CPU资源。

让线程主动释放资源的方法: Thread.join()、Thread.yeild()、Condition、Thread.sleep()、Object.wait()

Thread.sleep()和Object.wait()有什么区别?

Thread.sleep():使得线程释放CPU资源,但仍然占用锁。线程进入同步队列,等待一定时间才能后出队获取CPU资源。

Object.wait():使得线程是否CPU资源,且释放锁(sync)。线程进入阻塞队列,需要被外界唤醒并进入同步队列。

fork()

fork()函数通过系统调用,创建一个与原来进程几乎完全相同的子进程,原进程的数据、资源也会一同拷贝给子进程。父进程的fpid指向子进程的id,而子进程的fpid为0。而Linux在拷贝资源的过程中,就使用到了COW写时复制。

父进程调用fork()函数创建子进程后,内核会拷贝父进程的代码段、数据段、堆栈。然后为新生成的子进程分配虚拟空间,这个虚拟空间指向父进程的物理空间!然后子进程要调用exec()去执行其他程序嘛。如果在exec()之前父子进程中有相应的段发生新写入时,子进程的虚拟空间就指向之前内核拷贝出来的对应的段,这也就是写时复制;如果exec()之前父子进程指向的段没有产生变化,那么它们就永远指向这同一块物理空间。

零拷贝 - OS

零拷贝是为了降低操作时延、提高系统的吞吐量,它一般是针对文件传输来说的 image.png

文件如果直接分片传输,调用write()方法时会产生一次系统调用,进程需要陷入内核态,然后读取磁盘或网卡中的文件到内核态,再把数据从内核态拷贝到用户态,执行完成后进程再回到用户态执行程序。read()同理。这样一个文件分片的传输就需要四次上下文的切换,四次内存拷贝。

而对于文件传输来说,最终目的是把文件从一个磁盘传输到另一个磁盘,那么两次用户态的拷贝是不必须的。那么我们就可以使用零拷贝技术,在内核读取完文件后,直接把内核态的PageCache中的内容通过共享虚拟内存拷贝到Socket缓冲区,然后经过网络直接发送给对端网卡。这样就避免了用户态的参与,只需要两次上下文的切换和三次内存拷贝。但是,Socket缓冲区的可用空间是动态变化的,它会受到TCP滑动窗口、应用缓冲区、系统内存的影响,所以零拷贝也使得我们需要时刻关注Socket缓冲区的可用空间。 PageCache磁盘高速缓存:读取文件时,会把磁盘中的文件拷贝到PageCache上,然后再拷贝到进程中。这个PageCache使用LRU算法缓存磁盘中的文件,减少了读取磁盘的频率。而传输大文件时,PageCache被占满后依然还需要大量的文件数据传输,且会阻塞后续小文件的缓存作用。所以PageCache适用于处理小文件的传输。

ByteBuffer是堆外内存,在JVM之外,java可以直接访问,无需陷入内核态。

零拷贝 - Netty

Netty的零拷贝是站在用户空间,也就是JVM的角度,它主要是对ByteBuf中数据操作的优化。Netty在传输数据时需要把二进制数据拆分或合并成好几个数据包,所以ByteBuf肯定会进行数据拷贝来控制数据包的大小,所以Netty提供了很多类和操作来实现Netty的零拷贝。

CompositeByteBuf:将多个ByteBuf合并为一个逻辑上的ByteBuf,避免了多个ByteBuf之间的拷贝。

slice操作:将ByteBuf分解为多个共享同一块内存的ByteBuf,避免了内存拷贝。

wrap操作:将byte[]、ByteBuf、ByteBuffer等包装成一个大的ByteBuf对象,避免了内存拷贝。

Netty的ByteBuffer还可以采用Direct Buffer堆外内存,直接进行Socket的读写操作,减少上下文切换与数据拷贝的次数。 CPU缓存 内存由DRAM构成,CPU缓存由SRAM构成且离CPU核心更近,所以对于计算密集型的程序,从CPU缓存中取数据要比内存块很多。所以我们写代码要配合CPU缓存来写。CPU缓存有三级,从外到里越来越小。程序执行时,内存中的数据和指令经过三、二、一级缓存后才会被CPU使用(有CPU数据缓存和指令缓存)。所以CPU要操作的数据与指令在CPU缓存中,我们要做的就是提高CPU缓存的命中率。 提高数据缓存的命中率:利用数组连续存储的特点,尽量按照连续顺序访问数组,访问某个元素时,CPU会把这个元素后面的xxx字节一起载入缓存中,所以提高了CPU缓存的利用率。 提高指令缓存的命中率:CPU有分支预测器。所以我们先对数组排序再对每个元素进行if判断大小,CPU会预测到后面的指令而使用CPU指令缓存。 但是现在是多核CPU啊,所以进程在执行一段代码时可能会跳转使用多个CPU,所以我们可以尝试把进程绑定到一颗CPU上运行。多核CPU有缓存一致性原则,每个核都会通过嗅探在总线上传播的数据来检查自己的缓存值,若过期则失效。而一片连续的内存被加载到不同的核,他们是同一个cache line,有缓存一致性且加锁也是加载它上面,所以我们可以填充无用字节数使其分开,disruptor便用到了这一点。

image.png