进程、线程与协程

1. go中线程的数量

Go 使用Goroutine 调度器 (Scheduler) 来管理Goroutine的执行。调度器的核心概念如下

1.1. GMP模型

goalng采用特有的GMP模型。

  1. G(Goroutine):指的是 Go 代码中的 Goroutine。
  2. M(machine):它直接关联一个os内核线程,用于执行G。
  3. P(Processor):P里面一般会存当前goroutine运行的上下文环境(函数指针,堆栈地址及地址边界),P会对自己管理的goroutine队列做一些调度
    image.png
    P与M一般是一一对应的。P(上下文)管理着一组G(goroutine)挂载在M(内核线程)上运行,图中左边蓝色为正在执行状态的goroutine,右边为待执行状态的goroutiine队列。GO不能设置使用的线程数上限,但可以通过设置环境变量GOMAXPROCS的值或程序运行runtime.GOMAXPROCS()设置go进程使用的逻辑CPU核心数,间接影响线程的使用数量。
package main
import (
    "fmt"
    "runtime"
)

func main() {
    runtime.GOMAXPROCS(2) // 设定最多使用2个CPU核心
    fmt.Println("GOMAXPROCS:", runtime.GOMAXPROCS(0)) // 获取当前值
}

1.2. 默认GOMAXPROCS

[!note]

  • 在 Go 1.5 之前,GOMAXPROCS 默认是 1(单线程执行)。
  • Go 1.5 之后,Go 运行时默认将 GOMAXPROCS 设为 CPU 逻辑核心数runtime.NumCPU())。这样可以充分利用 CPU 并行计算能力,提高 Goroutine 执行效率
package main

import (
    "fmt"
    "runtime"
)

func main() {
    fmt.Println("默认 GOMAXPROCS:", runtime.GOMAXPROCS(0)) // 传入 0 只获取当前值,不修改
}

输出:默认 GOMAXPROCS: 32,查看系统CPU数量

devbox@devbox:~/project$ lscpu             
Architecture:            x86_64
  CPU op-mode(s):        32-bit, 64-bit
  Address sizes:         40 bits physical, 48 bits virtual
  Byte Order:            Little Endian
CPU(s):                  32
  On-line CPU(s) list:   0-31
Vendor ID:               GenuineIntel

1.3. GMP调度

当一个os线程在执行M1一个G1发生阻塞时,调度器让M1抛弃P,等待G1返回,然后另起一个M2接收P来执行剩下的goroutine队列(G2、G3...),这是golang调度器厉害的地方,可以保证有足够的线程来运行剩下所有的goroutine。
image.png
当G1结束后,M1会重新拿回P来完成,如果拿不到就丢到全局runqueue中,然后自己放到线程池或转入休眠状态。空闲的上下文P会周期性的检查全局runqueue上的goroutine,并且执行它。另一种情况就是当有些P1太闲而其他P2很忙碌的时候,会从其他上下文P2拿一些G来执行。
image.png

2. 进程、线程、协程

进程:系统中所有的应用程序都是以进程(process)的方式运行,是系统进行<span style="background:#fff88f">资源分配和调度的基本单位,每个进程都有自己的独立的地址空间,使得进程之间的地址空间相互隔离。
线程:线程是<span style="background:#fff88f">CPU调度的最小单元,通常意义上,一个进程由一个到多个线程组成,各个线程之间共享程序的内存空间(包括代码段、数据段、堆等)及一些进程级的资源(如打开的文件和信号)。
协程:协程在Go语言中,由轻量级线程实现,由Go运行时(runtime)管理。

[!note]
并发:多线程程序在单核上运行
并行:多线程程序在多核上运行

3. 协程与进程、线程的区别

1)进程拥有自己的堆栈,不共享堆和栈,是由操作系统进行调度的。
2)线程拥有自己的独立的栈和共享的堆,也是由操作系统进行调度。
3)协程共享堆,不共享栈,协程的调度由用户控制。

3.1. 协程的优点

  1. 代码开发简单,可以将异步处理逻辑代码用同步的方式编写,将多个异步操作集中到一个函数中完成。
  2. 单线程模式,没有线程安全的问题,不需要加锁操作。
  3. 性能好,协程是用户态线程,切换更加高效。
  4. Go协程占用内存小。执行Go协程只需要极少的栈内存(大概4~5KB),默认情况下,线程栈的大小为1MB。Goroutine就是一段代码,一个函数入口,以及在堆上为其分配的一个堆栈,所以它非常廉价,我们可以很轻松的创建上万个Goroutine,但它们并不是被操作系统所调度执行。

3.2. Go协程调用跟切换比线程效率高

线程并发执行流程:
线程是内核对外提供的服务,应用程序可以通过系统调用让内核启动线程,由内核来负责线程调度和切换,线程在等待IO操作时标为unrunnable状态会触发上下文切换。现代操作系统一般采用抢占式调度,上下文切换一般发生在时钟中断和系统调用返回前,调度器计算当前线程的时间片,如果需要切换就从运行队列中选出一个目标线程,保存当前线程的环境,并且恢复目标线程的运行环境,最典型的就是切换ESP指向目标线程内核堆栈,将EIP指向目标线程上次被调度出时的指令地址。一旦我们创建完线程,就无法决定它什么时候获得时间片,什么时候让出时间片,这里都交给了内核。但我们编写协程时可以控制,可控的切换时机和很小的切换代价,从操作系统有没有调度权来看,协程就是因为不需要进行内核态的切换

Go协程并发执行流程:
不依赖操作系统和其提供的线程,Golang自己实现的CSP并发模型实现:M,P,G
Go协程也叫用户态线程,协程之间的切换发生在用户态,在用户态没有时钟中断,系统调用等机制,因此效率高。