Go协程的运行机制以及并发模型

进程与线程

进程与线程都是os用来运行程序的基本单元。其中进程是正在执行的程序的实例,它包含了程序代码、数据、文件和系统资源等。进程是os资源分配的基本单元,每个进程都有自己独立的地址空间、文件描述符、网络连接、进程ID等系统资源。进程与进程之间有较好的隔离性,但是进程之间的通信困难,创建一个进程耗时且耗资源,因此多进程并不是高并发场景下的最佳选择。

由于多进程在并发条件下的不足,os抽象出一个轻量级的资源------线程。一个进程可以包含多个线程,这些线程共享进程的资源,包括内存,文件和网络描述符等。同时,每个线程都有独立的栈空间、程序计数器和线程本地存储等资源。线程是os资源调度的基本单位,它比进程更轻量级,可以被更快地创建和销毁,且线程间地切换开销比进程小,因此在多任务处理中,使用线程可以提高程序地并发性和性能。

线程与协程

协程一般被认为是轻量级地线程,os感知不到协程的存在,协程的管理依赖Go语言运行时自身提供的调度器。因此准确的说,Go语言中的协程是从属某一个线程的,只有协程和实际线程绑定,才有执行的机会。

下面从调度方式、上下文切换的速度、调度策略、栈的大小这4个方面分析一下线程和协程的不同之处。

  • 调度方式
    Go语言中的协程从属于某一个线程的,协程与线程是多对多的对应关系。Go语言调度器可以将多个协程调度到同一个线程中执行,一个协程也可以在多个线程中切换。
  • 上下文切换速度
    协程上下文切换的速度要快于线程,因为切换协程不必同时切换开发者态与os内核态,而且在Go语言中,切换协程只需要保留极少的状态和寄存器值,切换线程则会保留额外的寄存器值。在一般情况下,线程切换的速度大约为1~2微秒,Go语言中协程切换的速度比它快数倍,为0.2微秒左右。
  • 调度策略
    线程的调度在多数时间里是抢占式的,os调度器为了均衡每个线程的执行周期,会定时发出中断信号强制切换线程上下文。而Go语言中的协程在一般情况下是协作式调度的,当一个协程处理完自己的任务后,可以主动将执行权限让渡给其他协程。这意味着协程可以更好地在规定时间内完成自己的工作,而不会轻易被抢占。只有当一个协程运行了太长时间时,Go语言调度器才会强制抢占其任务的执行。
  • 栈的大小
    线程的栈的大小一般是在创建时指定的。为了避免出现栈溢出的情况,默认的栈较大(例如2MB),这意味着每创建1000个线程就需要消耗2GB的虚拟内存,大大限制了可以创建的线程的数量,而Go语言中的协程栈默认是2KB,所以在实践中,经常会看到成千上万的协程存在。
    另外,线程的栈在运行时不能更改,但是Go语言中的协程栈在Go运行时的帮助下会动态检测栈的大小,并动态地进行扩容,因此在实践中,我们可以将协程看作轻量的资源。

协程的数据争用

在Go语言中,当两个以上协程同时访问相同的内存空间,并且至少有一个写操作时,可能出现并发安全问题,这种现象也叫做数据争用。而要解决数据争用问题,我们需要一些机制来保证某一时刻只有一个协程主席特定操作,比较传统的方案是锁,包括原子锁、互斥锁与读写锁。

  • 原子锁

    go 复制代码
    var count int64 =0
    func add(){
       atomic.AddInt64(&count,1)
    }
    func main(){
       go add()
       go add()
    }

    sync/atomic包中还有一个重要的功能------CompareAndSwap,它能够对比并替换元素值。在下面这个例子中,atomic.CompareAndSwapInt64会判断flag变量的值是否为0,如果是0,则将flag的值设置为1.这一系列操作都是原子性的,不会发生数据争用,也不会出现内存操作乱序问题。sync/atomic包中的原子操作能够构建起一种自旋锁,只有获取该锁,才能执行区域中的代码。

    go 复制代码
     var count int64 =0
     var flag int64 = 0
     func add(){
     for{
       if atomic.CompareAndSwapInt64(&flag,0,1){
          count++
          atomic.StoreInt64(&flag,0)
          return
       }
     }
     }
     func main(){
        go add()
        go add()
     }
  • 互斥锁

    通过原子操作构建起的自旋锁,虽然简单高效却不是万能的。例如,当某一个协程长时间霸占锁时,其他协程仍在继续抢占锁,这会导致CPU资源持续无意义地被浪费。同时,当许多协程同时获取锁时,可能有协程始终抢占不到锁。为了解决这种问题,os的锁接口提供了终止与唤醒的机制,这就避免了频繁自旋造成的浪费。不过,调用os级别的锁会锁住整个线程使之无法运行,另外所得抢占还涉及线程之间的上下文切换。Go语言借助协程实现了一种比传统os级别的锁更加轻量级别的互斥锁。

    go 复制代码
    var count int64=0
    var m sync.Mutex
    func add(){
      m.Lock()
      count++
      m.Unlock()
    }
    func main(){
       go add()
       go add()
    }

    这里,sync.Mutex构建起了互斥锁,在同一时刻,只会有一个获取了锁的协程会继续执行任务,其他的协程将陷入等待状态。借助协程的休眠与调度器的调度,这种锁会变得非常轻量。

  • 读写锁

    由于在同一时间内只能有一个协程获取互斥锁并执行操作,因此在多读少写的情况下,如果长时间没有写操作,读取到的会是完全相同的值,使用互斥锁就显得没有必要了,这时使用读写锁更加恰当。

    读写锁通过两种锁来实现,一种为读锁,一种为写锁。当进行读取操作时,需要加读锁,当进行写入操作时,需要加写锁。多个协程可以同时获得读锁并执行,但只能有一个协程获得写锁。如果此时有协程申请了写锁,那么该协程需要等待所有的读锁都被释放才能获取写锁并执行。如果当前的协程申请了读锁时已经存在写锁,那么需要等待写锁被释放再获取读锁并执行。

    go 复制代码
    type Stat struct{
      counters map[string]int64
      mutex sync.RWMutex
    }
    func (s *Stat)getCounter(name string) int64{
      s.mutex.RLock()
      defer s.mutex.RUnlock()
      return s.counters[name]
    }
    func (s *Stat) SetCounter(name string){
      s.mutex.Lock()
      defer s.mutex.Unlock()
      s.counters[name]++
    }

Go并发控制库

  • sync.WaitGroup

    sync.WaitGroup能够协调多个协程之间的并发执行,它会等待多个协程执行完毕再继续执行后续代码。先来看下这样一个场景:在加载配置的过程中,我们希望多个协程可以同时加载不同的配置文件,同时希望这些协程都加载完毕程序才提供服务。这时,使用sleep函数进行休眠是一种低效的解决方案,更高效的方案是使用Go语言标准库中的sync.WaitGroup。

    sync.WaitGroup提供了3种方法:Add、Done和Wait。其中Add方法将等待的数量加1,Done方法将等待的数量减1,Wait方法则会陷入等待,直到等待的数量为0。因此,一般在开启协程前调用Add方法;然后开启多个工作协程,在每个协程结束时延迟调用Done方法,将等待的数量减1;在末尾调用Wait方法,该方法会陷入阻塞,等待所有协程执行完毕再继续执行后续代码。

    go 复制代码
    func worker(id int){
      //.....
    }
    func main(){
      var wg sync.WaitGroup
      for i :=1;i<=5;i++{
         wg.Add(1)
         i :=i
         go func(){
            defer wg.Done()
            worker(i)
         }()
      }
      wg.Wait()
    }
  • sync.Once

    sync.Once可以保证某一个过程只执行一次,它在实践中被广泛使用,用于防止内存泄漏、资源重复关闭等异常情况。例如,我们希望再程序启动时仅加载一次配置、初始化一次日志组件。

    go 复制代码
    var(once sync.Once)
    func DbOnce()(*sql.DB,error){
      once.Do(func(){
         db,dbErr 
      })
    }
  • sync.Map

    sync.Map是Go语言标准库提供的一种线程安全的map类型。与常规的map类型不同,sync.Map是并发安全的,可以在多个协程之间共享访问。

    sync.Map的使用非常简单,只需要使用sync.Map的内置方法进行读写操作即可。例如,可以使用Load方法读取某个Key对应的Value,使用Store方法存储Key-Value对,使用Delete方法删除指定的Key,等等。具体来说,sync.Map支持以下几个方法。

    • Load(key interface{})(interface{},bool):加载指定Key对应的Value。
    • Store(key, value interface{}):存储Key-Value对。
    • LoadOrStore(key,value interface{})(actual interface{},loaded bool):加载Key对应的Value,如果Key不存在,则存储Key-Value对,并返回(value,false);如果Key已经存在,则返回已经存在的Value,并返回(value,true)。
    • Delete(key interface{}):删除指定的Key-Value对。
    • Range(f func(key,value interface{})bool):遍历sync.Map中的所有Key-Value对,并对每个Key-Value对执行指定的函数f。如果函数f返回false,则Range方法会停止遍历。
    go 复制代码
    func main(){
       var m sync.Map
       m.Store("foo","bar")
       m.Store("hello","world")
       val,ok := m.Load("foo")
       if ok {
          fmt.Println(val)//bar
       }
       newVal,loaded :=m.LoadOrStore("foo","baz")
       if loaded{
          fmt.Println(newVal)//bar
       }else{
          fmt.Println("Stored value for key 'foo'")
       }
       m.Range(func (key,value interface{})bool{
          fmt.Println("key: %v,value: %v\n",key,value)
          return true
       })
    }

    需要注意的是,由于sync.Map内部实现了一些复杂的算法,因此在性能上可能略逊于普通的map类型。另外,由于sync.Map中的key和value都是interface{}类型,因此在使用时需要进行类型断言。

  • sync.Cond

    sync.Cond是Go语言提供的一种类似条件变量的同步机制,它能够让协程陷入阻塞,直到某个条件发生再继续执行。sync.Cond包含了3个重要的API:Wait()、Signal()和Broadcast()。其中,Wait()表示等待条件的发生,会释放所持有的锁,并使当前协程陷入等待状态;Signal用于唤醒等待队列中的一个协程;而Broadcast会唤醒所有等待的协程。要注意的是,使用Wait之前必须调用Cond.L.Lock进行枷锁,结束后还需要调用Cond.L.UnLock()进行解锁。

    使用sync.Cond的正确方法是:协程A会用for循环判断是否满足条件,如果不满足则陷入休眠状态。协程B会在恰当的时候调用c.Broadcast()唤醒等待的协程。当协程被唤醒后,需要再次检查条件是否满足,如果不满足则需要重新陷入等待。

    使用sync.Cond可以实现某种程度上的解耦:消息的发出者不需要知道具体的判断条件,这样可以增强代码的可维护性和可扩展性。

    go 复制代码
    //协程A
    c.L.Lock()
    for !condition(){
      c.Wait()
    }
    ...
    c.L.Unlock()
    
    //协程B
    c.Broadcast()

    在实践中,并不经常使用sync.Cond,因为在很多场景下都可以使用更为强大的通道。

Go并发模式

前面讲了很多传统的同步模式,但是在实践中协调协程时,使用最多的还是通道。通道最厉害之处在于,在通道的过程中完成了数据所有权的转移,数据只可能在某一个协程中执行,这在无形中解决了并发安全的问题。

  • ping-pong模式

    收到数据的协程可以在不加锁的情况下对数据进行处理,而不必担心并发冲突。

    go 复制代码
    func main(){
      var Ball int
      table :=make(chan int)
      go player(table)
      go player(table)
      table<-Ball
      time.Sleep(1*time.Second)
      <-table
    }
    func player(table chan int){
       for{
         ball :=<-table
         ball++
         time.Sleep(1*time.Second)
         table <- ball
       }
    }
  • fan-in模式

    多个协程把数据写入通道,但只有一个协程等待读取通道数据。

    go 复制代码
    func search(msg string)chan string{
      var ch = make(chan string)
      go func(){
        var i int
        for{
          ch <- fmt.Sprintf("get %s %d",msg,i)
          i++
          time.Sleep(1*time.Second)
        }
      }()
      return ch
    }
    func main(){
       ch1 := search("jonson")
       ch2 := search("olaya")
       for{
         select{
           case msg := <-ch1:
           fmt.Println(msg)
           case msg := <-ch2:
           fmt.Println(msg)
         }
       }
    }
  • fan-out模式

    一个协程完成数据写入,多个协程争夺同一个通道中的数据。fan-out模式通常用来分配任务。例如,程序消费kafka等中间件的数据,多个协程会监听同一个通道中的数据,并在读取到数据后立即进行后续处理,处理完毕再继续读取,循环往复。

    go 复制代码
    func worker(tasksCh <- chan int,wg *sync.WaitGroup){
      defer wg.Done()
      for{
        task,ok:=<-tasksCh
        if !ok{
          return
        }
        d := time.Duration(task)*time.Millisecond
        time.Sleep(d)
        fmt.Println("processing task",task)
      }
    }
    
    func pool(wg *sync.WaitGroup,workers,tasks int){
      tasksCh := make(chan int)
      for i:=0;i<workers;i++{
        go worker(tasksCh,wg)
      }
      for i:=0;i<tasks;i++{
        tasksCh <- i
      }
      close(tasksCh)
    }
    
    func main(){
      var wg sync.WaitGroup
      wg.Add(36)
      go pool(&wg,36,50)
      wg.Wait()
    }
  • pipeline模式

    指由通道连接的一系列连续的阶段,以类似流的形式进行计算。每个阶段由一组执行特定任务的协程组成,通过通道获取上游传递过来的值,经过处理后,再将新的值发送给下游。

    go 复制代码
    func Generate(ch chan<- int){
      for i:=2;;i++{
        ch <- i
      }
    }
    func Filter(in <-chan int,out chan <- int,prime int){
      for{
        i := <-in
        if i%prime!=0{
          out <- i
        }
      }
    }
    func main(){
      ch := make(chan int)
      go Generate(ch)
      for i:=0;i<10000;i++{
        prime := <-ch
        fmt.Println(prime)
        ch1 := make(chan int)
        go Filter(ch,ch1,prime)
        ch = ch1
      }
    }
相关推荐
AndyFrank几秒前
mac crontab 不能使用问题简记
linux·运维·macos
激流丶13 分钟前
【Kafka 实战】如何解决Kafka Topic数量过多带来的性能问题?
java·大数据·kafka·topic
筱源源16 分钟前
Kafka-linux环境部署
linux·kafka
Themberfue17 分钟前
Java多线程详解⑤(全程干货!!!)线程安全问题 || 锁 || synchronized
java·开发语言·线程·多线程·synchronized·
让学习成为一种生活方式34 分钟前
R包下载太慢安装中止的解决策略-R语言003
java·数据库·r语言
晨曦_子画39 分钟前
编程语言之战:AI 之后的 Kotlin 与 Java
android·java·开发语言·人工智能·kotlin
算法与编程之美1 小时前
文件的写入与读取
linux·运维·服务器
南宫生1 小时前
贪心算法习题其三【力扣】【算法学习day.20】
java·数据结构·学习·算法·leetcode·贪心算法
xianwu5431 小时前
反向代理模块
linux·开发语言·网络·git
Heavydrink1 小时前
HTTP动词与状态码
java