进程、线程、协程、goroutine

130 阅读9分钟

进程

我们编写的代码只是一个存储在硬盘的静态文件,通过编译后就会生成二进制可执行文件,当我们运行这个可执行文件后,它会被装载到内存,接着CPU会执行程序中的每一条指令,那么这个运行中的程序就是一个进程。
进程就是把内存分成若干个部分。不同程序使用各自的内存空间互不干扰。CPU可以在多个进程之间切换执行,比如当进程1需要从硬盘读取数据,CPU不需要阻塞等待,而是去执行另外的进程。当硬盘读取数据结束,CPU会收到中断信号,继续运行这个进程1,所以进程的出现可以提高CPU利用率。

进程的状态

  • 运行状态: 进程占用CPU
  • 就绪状态: 可运行,由于其他进程处于运行状态而暂时停止运行
  • 阻塞状态: 该进程正在等待某一时间发送而暂时停止运行。
  • 创建状态: 进程正在被创建时的状态
  • 结束状态: 进程正在从系统中消失时的状态
  • 阻塞挂起状态: 进程在硬盘中并等待某个事件的出现
  • 就绪挂起状态: 进程在硬盘中,只要进入内存,即刻立刻运行。

上下文切换

定义:一个进程切换到另一个进程运行
进程的切换只能发生在内核态。

上下文中包含什么

虚拟内存、栈、全局变量等用户空间的资源,还包含了内核堆栈,寄存器等内核空间的资源。

上下文切换流程

举例把进程1切换到进程2

  • 进程1运行时间结束
  • 把进程1的上下文保存在进程的PCB控制块中
  • 从进程2的PCB控制块取出进程2的上下文
  • 运行进程2

image.png

线程

比进程更小的独立运行的基本单位。是CPU调度的最小单位。

为什么需要线程?

  • 减少上下文切换时的开销,线程之间的上下文切换的开销比进程小。
  • 一个进程内需要并发执行多个函数,比如我们播放一个视频,我们需要同时读取文件数据,解压缩数据,播放解压后的数据,那么如果我们只用一个进程运行视频播放那么视频播放可能会阻塞在前两步操作中。导致画面卡顿。
  • 线程之间可以共享相同的地址空间。

线程的优点:

  • 一个进程中可以同时存在多个线程
  • 各个线程之间可以并发执行
  • 各个线程之间可以共享地址空间和文件等资源。

线程的缺点

  • 当进程中的一个线程崩溃时,会导致其所属进程的所有线程崩溃。
  • 线程创建的数量有限。随着线程的数量增多,上下文切换的开销会增加。
  • 上下文切换需要从用户空间切换到内核空间,也就是特权模式切换。

线程的三种实现方式

  • 用户线程:在用户空间实现的线程,不是由内核管理的线程,是由用户态的线程库来完成线程的管理
  • 内核线程: 在内核中实现的线程,是由内核管理的线程
  • 轻量级线程: 在内核中支持用户线程

进程与线程的比较

  • 进程是资源分配的单位,线程是CPU调度的单位
  • 进程拥有一个完整的资源平台包括文本区域(text region)数据区域(data region)和堆栈stack region)而线程只独享必不可少的资源,如寄存器和栈
  • 线程具有就绪、阻塞、执行三种基本状态
  • 线程能减少并发执行的时间和空间开销。

进程调度算法

  • 先来先服务算法,先到达的作业优先被CPU执行,后到达的作业进入调度队列等待。一个作业执行完后从调度队列取出下一个作业执行。对长作业友好,对短作业不友好,短作业执行时间短,却需要等待很长时间。
  • 短作业优先算法,根据作业完成时间的长短来进行优先调度,作业越短,优先级越高。这种调度算法对短作业友好,对长作业不友好。如果频繁的有短作业到达,长作业会一直延迟执行
  • 高响应比优先调度算法: 权衡了短作业和长作业。每次进行进程调度时,先计算响应比优先级,然后把响应比优先级最高的进程投入运行。响应比为等待时间+服务时间/服务时间
  • 时间片轮转算法:最简单、最公平且使用最广的算法,每个作业都会有一个规定的时间片运行时间,先到达的作业会优先被执行。但是运行时间由时间片决定。如果在时间片内的时间作业运行完,会继续执行下一个作业。如果在时间片内作业没有运行完,该作业会放入就绪队列的末尾等待下一次执行。
  • 优先级调度算法 ,从就绪队列中选择最高优先级的进程进行运行。分为静态优先级和动态优先级。静态优先级的进程优先级不会改变。动态优先级的进程的优先级会随着时间而增加。同时该算法还有两种处理优先级高的方法,非抢占式和抢占式。非抢占式当就绪队列中出现优先级高的进程,会先运行完当前进程,再执行高优先级进程。抢占式:当就绪队列中出现优先级高的进程会立刻触发中断挂起当前进行,调度优先级高的进程运行。
  • 多级反馈队列调度算法 综合了时间片轮转算法和最高优先级算法。 多级表示拥有多个队列,每个队列的优先级不同。当一个进程在优先级较高的队列中没有运行完成就会进入优先级较低的队列等待运行。反馈指的是有新的进程加入优先级高的队列时,会立刻停止当前正在运行的进程,转而去运行优先级高的队列。

协程

协程实际上是一种用户态线程,它不由操作系统调度,而是由用户自身完成线程的管理。包括线程的创建、调度、销毁。多个协程可以同时对应多个线程。可以是1:1 M:1 M:N的关系。 优点:比线程更加轻量,上下文切换的开销更小,进一步提升CPU利用率。
缺点:当一个协程运行后,除非它主动交出CPU的使用权,否则其他线程都无法运行。因为用户态的线程没法打断当前运行中的线程。只有操作系统可以。但是用户线程不是由操作系统管理的。

goroutine

goroutine是由go语言自身实现的一种协程。是M:N的对应关系。goroutine非常的轻量,一个goroutine只占几KB,这就能在有限的内存空间内支持更大的并发。 优点:

  • 当一个goroutine因为系统调用阻塞时,goroutine会释放的自己的P并且被剥夺CPU,转移给其他空间的线程执行。直到系统调用完成,调度器会重新调用该goroutine
  • 同时goroutine的调度是比较公平的,并且是抢占式调度机制,一个goroutine最多占用CPU10ms,防止其他goroutine被饿死
  • 即使创建了成千上万个goroutine也不会对上下文的切换造成太大的影响,因为多个goroutine发生切换的时候是在同一个线程下面的,也就是只在用户空间完成,同时切换的时候只会切换程序计数器和栈指针,上下文的切换几乎达到了O(1)时间复杂度。

goroutine是怎么实现的

Go语言的协程(goroutine)是通过Go语言的运行时(runtime)实现的。每当一个Go程序启动时,它会创建一个操作系统线程(OS thread),然后在该线程中创建一个Go语言运行时(runtime),该运行时会管理goroutine的创建、调度和销毁。

Go语言中的每个goroutine都有自己的调用栈(call stack),它是由Go语言运行时自动管理的。当一个goroutine被创建时,它的调用栈大小会根据需要进行动态调整。这意味着一个goroutine可以拥有非常小的调用栈(只有几千字节),因此可以轻松地创建大量的goroutine。

在Go语言中,通过使用关键字go可以创建一个goroutine。当使用go关键字时,Go语言运行时会将当前函数包装成一个新的goroutine,并将该goroutine放入调度器(scheduler)中等待执行。一旦goroutine可以运行,调度器就会选择一个可以运行的goroutine并将其放到操作系统线程中运行。

由于Go语言运行时具有高效的调度机制和动态调整调用栈大小的能力。

goroutine 可以无限创建吗?

每个goroutine都是有一定的开销的,所以答案肯定是不可以的。

如何控制goroutine的数量

  • 使用有缓冲channel和waitgroup控制
package main

import (
    "fmt"
    "math"
    "sync"
    "runtime"
)

var wg = sync.WaitGroup{}

func busi(ch chan bool, i int) {

    fmt.Println("go func ", i, " goroutine count = ", runtime.NumGoroutine())

    <-ch

    wg.Done()
}

func main() {
    //模拟用户需求go业务的数量
    task_cnt := math.MaxInt64

    ch := make(chan bool, 3)

    for i := 0; i < task_cnt; i++ {
		wg.Add(1)

        ch <- true

        go busi(ch, i)
    }

	  wg.Wait()
}
  • 利用无缓冲channel与任务发送/执行分离方式
package main

import (
    "fmt"
    "math"
    "sync"
    "runtime"
)

var wg = sync.WaitGroup{}

func busi(ch chan int) {

    for t := range ch {
        fmt.Println("go task = ", t, ", goroutine count = ", runtime.NumGoroutine())
        wg.Done()
    }
}

func sendTask(task int, ch chan int) {
    wg.Add(1)
    ch <- task
}

func main() {

    ch := make(chan int)   //无buffer channel

    goCnt := 3              //启动goroutine的数量
    for i := 0; i < goCnt; i++ {
        //启动go
        go busi(ch)
    }

    taskCnt := math.MaxInt64 //模拟用户需求业务的数量
    for t := 0; t < taskCnt; t++ {
        //发送任务
        sendTask(t, ch)
    }

	  wg.Wait()
}

使用第二种方式更佳,因为第二种方式既可以控制生产者的速率也可以控制消费者的速率
参考: