golang并发编程

94 阅读12分钟

进程、线程和协程

进程 是操作系统中执行任务的基本单位,它是一组运行在一个中央处理器上的指令,它可以通过操作系统分配内存和资源来完成各种任务。

线程 是操作系统的最小调度单位,它是进程的一个执行流。 一个进程可以包含多个线程,同时多个线程也可以共享进程的资源。线程是操作系统内核提供的一种执行单元,可以被调度执行,并且拥有自己的内存空间。

协程 是一种轻量级的线程,可以在单个进程中同时执行多个任务。 和普通的线程不同,协程不需要操作系统来创建和调度,因此它比线程更轻巧。

Go语言中的协程(又称为"goroutine")是由Go语言内部实现的轻量级线程。

1、内存占用小,创建一个g 栈内存消耗只需要2kB;

2、由Go runtime管理而不是操作系统,所以创建和销毁的消耗小;

3、goroutine切换只需要保存三个寄存器,耗时短。

线程是比协程更底层的概念,它需要操作系统来创建和调度。 线程也比协程更加复杂,因为它需要手动管理线程的生命周期,包括创建、启动、挂起和终止等。

在计算机系统中,通常使用多线程来解决并发问题。线程是操作系统内核提供的一种执行单元,可以被调度执行,并且拥有自己的内存空间。然而,传统的线程有一些问题需要解决。首先,线程的创建和销毁需要消耗大量的系统资源,特别是内存。其次,线程之间的切换需要操作系统进行上下文切换,会消耗大量的时间和资源。为了解决这些问题,就出现了协程这种机制。

线程是进程的一个执行单元,进程是计算机中执行任务的基本单位,而协程是一种轻量级的线程。

两级线程模型

用户级线程(User-level threads)

  • 这些线程由用户程序在用户空间管理,通常由用户程序或者编程语言库(例如Go的goroutines)提供支持。
  • 用户级线程是轻量级的线程,线程的创建、调度、销毁等操作在用户空间完成,不需要内核的参与。
  • 用户级线程的切换不需要内核模式切换,因此切换速度较快,但缺点是不能充分利用多核处理器,因为用户级线程是在一个内核线程上运行的。
  • 如果一个用户级线程发生了阻塞,整个进程中的所有用户级线程都会被阻塞,因为内核并不知道用户级线程的存在。

内核级线程(Kernel-level thread)

  • 这些线程由操作系统内核来管理,操作系统负责线程的创建、调度、销毁等操作。
  • 内核级线程是重量级的线程,线程的切换需要进行内核模式切换,开销较大。
  • 内核级线程可以利用多核处理器的优势,因为操作系统可以将不同的内核级线程分配到不同的处理器核心上运行。
  • 当一个内核级线程发生阻塞时,操作系统可以将其他内核级线程切换到运行状态,从而充分利用系统资源。

两级线程模型(M个线程 : N个内核线程)

image.png

GMP模型

GMP模型是对两级线程模型的改进,因为CPU感知不到Goroutine,只知道内核线程,所以需要Go调度器(GMP模型)将协程调度到内核线程上面去,然后操作系统调度器将内核线程放到CPU上去执行。

G(Goroutine): Goroutine,存储了Goroutine的执行栈信息,是参与调度与执行的最小单位。

M(Machine): 是对操作系统线程(内核级线程)的封装,默认数量限制是10000,可以通过 debug.SetMaxThreads() 方法进行设置,如果有M空闲,那么就会回收或者睡眠。

P(Processor): 指的是逻辑处理器,代表了M执行G所需要的资源和上下文环境,用来调度GM之间的关联关系,只有将PM绑定,才能让中挂载的G运行起来,因此P的数量决定了系统内最大可并行的G的数量,其数量可通过GOMAXPROCS()来设置,默认为CPU核心数。

Sched: 调度器,它维护有存储M和G的全局队列,以及调度器的一些状态信息

调度流程

image.png

  • P有任务,需要创建或者唤醒一个M去处理它队列中的任务,
  • 一个M对应一个P,一个P下面挂多个G,但同一时间一个M上只有一个G在跑,其余都是放入等待队列中。
  • M关联的P的本地队列消费完了,就会去全局队列里取G,如果全局队列里也消费完了,会去network poller(网络轮询器)中取,最后会去其他P的队列里抢G (Work Stealing机制),如果都没有,那么M会进入睡眠状态,等待被其它工作线程唤醒。。
  • G阻塞时,P会和M解绑,去寻找下一个可用的MGM在阻塞结束之后会优先寻找之前的P,如果此时P已绑定其他MM会获取一个空闲的P绑定当前G,如果没有,M会进入休眠,G则是以可运行的状态进入全局队列(Hand Off机制

抢占式调度

在1.2版本之前,Go的调度器仍然不支持抢占式调度,程序只能依靠Goroutine主动让出CPU资源才能触发调度,这会引发一些问题,比如:

  • 某些 Goroutine 可以长时间占用线程,造成其它 Goroutine 的饥饿
  • 垃圾回收器是需要stop the world的,如果垃圾回收器想要运行了,那么它必须先通知其它的goroutine停下来,这会造成较长时间的等待时间

为解决这个问题:

基于协作的抢占式调度

  • 系统发现运行时间过长的G,打上抢占标记,函数调用前插入runtime.morestack检查如果G有抢占标记,会触发抢占让出cpu,但是只在有函数调用的地方才能插入抢占代码(埋点),对于没有函数调用而是纯算法循环计算的G,无法抢占

基于信号的抢占式调度

  • 真正的抢占式调度是基于信号完成的,所以也称为“异步抢占”。不管协程有没有意愿主动让出cpu运行权,只要某个协程执行时间过长,就会发送信号强行夺取cpu运行权。

  • M注册信号处理函数sighandler,它会间隔性的进行监控,如果发现某个G独占P时间过长,就会给M发送抢占信号,在收到信号后,内核执行 sighandler 函数把当前G的状态从_Grunning(正在执行)改成 _Grunnable(可执行) 并放回全局队列,然后继续执行下一个G

runtime

runtime包是Go语言的运行时系统,提供了与底层系统交互和控制的功能。它包含了与内存管理、垃圾回收、协程调度等相关的函数和变量。

runtime.NumGoroutine() 获取当前goroutine数量

Goroutine

Goroutine可以理解为一种Go语言的协程,是Go支持高并发的基础,属于用户态的线程,goroutine和内核线程的区别主要有三点:

1、内存占用小,创建一个g 栈内存消耗只需要2kB;

2、由Go runtime管理而不是操作系统,所以创建和销毁的消耗小

3、goroutine切换只需要保存三个寄存器,耗时短。

通过go关键字创建新的goroutine 通过GMP模型实现goroutine调度 通过channel实现goroutine之间的通信

并发安全性

Goroutine 的出现使得 Go 语言可以更加方便地进行并发编程。但是在使用 Goroutine 时需要注意避免资源竞争和死锁等问题。

当多个Goroutine并发修改同一个变量时有可能会产生并发安全问题导致结果不一致, 因为修改操作可能是非原子的,这种情况可以

  • 将修改变成原子操作(atomic)
  • 通过加锁保护(sync.Mutex, sync.RWMutex)
  • 使用channel来进行goroutine之间的通信

让修改的步骤串行执行防止并发安全问题。

原子操作

Go atomic包是最轻量级的锁(也称无锁结构),常常直接通过CPU指令直接实现,可以在不形成临界区和创建互斥量的情况下完成并发安全的值替换操作,不过这个包只支 int32 / int64 / uint32 / uint64 / uintptr 这几种数据类型的一些基础操作,常见操作有

  • 增减Add
  • 载入Load
  • 比较并交换CompareAndSwap(该操作简称 CAS,可以用来实现乐观锁)
  • 交换Swap
  • 存储Store
func main() {  
    var wg sync.WaitGroup  
    var num int64  
      
    wg.Add(1)  
    go func() {  
        defer wg.Done()  
        for i := 0; i < 10; i++ {  
            atomic.AddInt64(&num, 1)  // 如果不使用原子操作,下面的打印就会乱序
            println(num)  
        }  
    }()  
  
    wg.Add(1)  
    go func() {  
        defer wg.Done()  
        for i := 0; i < 10; i++ {  
            atomic.AddInt64(&num, 1)  
            println(num)  
        }  
    }()  
    wg.Wait()  
}

原子操作和锁的区别

原子操作和锁存在于各个指令/语言层级,比如“机器指令层级”,“汇编指令层级”,“Go语言层级”等。他们的区别包括:

  • 原子操作由底层硬件支持,而锁是基于原子操作+信号量完成的。若实现相同的功能,前者通常会更有效率。
  • 原子操作是单个指令的互斥操作;互斥锁/读写锁是一种数据结构,可以完成临界区(多个指令)的互斥操作,扩大原子操作的范围。
  • 原子操作是无锁操作,属于乐观锁;锁一般属于悲观锁。

互斥锁(sync.Mutex)

Mutex对外只暴露两个方法,分别是加锁Lock和解锁Unlock,当一个goroutine获取到互斥锁后,其他的goroutine只能排队等待它解锁后才能使用,需要注意的是,互斥锁是不可重入锁,就说锁在实现过程中没有记录具体是哪个goroutine拥有它,一个 goroutine可以Lock,另一个 goroutine可以Unlock,拥有锁的协程重复加锁会导致死锁。

保证在同一时间只有一个 Goroutine 能够访问临界区,这会牺牲一定的性能。

image.png

map是并发不安全的,多个goroutine同时访问同一个map会报错 fatal error: concurrent map writes 这个时候需要加锁来保证并发的安全性

func main() {  
    var wg sync.WaitGroup  
    m := make(map[string]int)  
    var mu sync.Mutex  
  
    var write func(key string, value int)  
    write = func(key string, value int) {  
        defer wg.Done()  
        mu.Lock()  
        m[key] = value  
        mu.Unlock()  
    }  
  
    wg.Add(2)  
    go write("key1", 1)  
    go write("key1", 2)  
    wg.Wait()  
    println(m["key1"])  
}

由于锁的随机竞争,上面这段代码会随机输出1或者2,如果对性能要求较高,可以考虑用sync.Map,来减少锁竞争,sync.Map的读取操作可以并发进行,不需要额外的加锁,但如果你需要进行其他操作,需要通过Delete方法删除key、Store方法更新value等...

也可以使用

读写锁(sync.RWMutex)

sync.RWMutex 并没有固定优先策略,而是由当前系统的调度器和运行时环境来决定锁的获取顺序。在某些情况下,可以通过设置写优先策略来提供一些性能优势。

  • 避免写饥饿:在读优先策略下,如果有大量的读操作持续不断地访问共享资源,可能会导致写操作一直无法获取到锁,从而发生写饥饿现象,降低写操作的执行性能。而写优先策略可以避免这种情况,确保写操作有机会在读操作之前获取到锁,从而保证写操作不会一直被阻塞。
  • 提高写操作的响应性:在读优先策略下,如果有大量的读操作正在进行,写操作可能需要等待很长时间才能获取到锁,从而导致写操作的响应性较低。而写优先策略可以让写操作在读操作之前获取到锁,从而提高写操作的响应性,减少写操作的等待时间。

channel

Go中提倡通过通信来共享内存,实现CSP(通信顺序进程)并发模型,其中就是通过channel来实现goroutine之间的通信。这使得只有一个协程(goroutine)能够访问数据,避免了竞态条件的出现。

底层原理 见------

下面是一个例子,实现一个线程打印奇数,一个线程打印偶数

func main() {  
    var (  
        num = 100  
        wg sync.WaitGroup  // 控制goroutine的结束时间
        msg = make(chan int) // 无缓冲channel实现交替打印,不能用锁,g随机竞争不能保证顺序打印  
    )
    
    wg.Add(1)  
    go func(chan int) {
        defer wg.Done()
        for i := 1; i <= num; i++ {  
            msg <- i  
            if i%2 == 1 {  
                fmt.Println("goroutine-1:", i)  
            }  
        }    
    }(msg)  
  
    wg.Add(1)  
    go func(chan int) {
        defer wg.Done()
        for i := 1; i <= num; i++ {  
            <-msg  
            if i%2 == 0 {  
                fmt.Println("goroutine-2:", i)  
            }  
        }  
    }(msg) 

    wg.Wait()  
}

select

  • 在Go语言中,select 是一个关键字,用于监听和 channel 有关的IO操作。
  • 通过 select 语句,我们可以同时监听多个 channel,并在其中任意一个 channel 就绪时进行相应的处理。
  • 如果多个通道都已经就绪,select 语句会随机选择一个通道来执行。这样确保了多个通道之间的公平竞争。
select {
    case <-channel1:
        // 通道 channel1 就绪时的处理逻辑
    case data := <-channel2: 
        // 通道 channel2 就绪时的处理逻辑
    case <-time.After(3 * time.Second): 
        // 超时处理逻辑
}