Go并发之道(学习笔记)

85 阅读13分钟

并发概述

竞争条件

  • 当两个或多个操作必须按照正确的顺序执行,而程序并未保证这个顺序,就会发生竞争条件

原子性

  • 当某些东西被认为是原子的,或者具有原子性的时候,这意味着他运行的环境中,它是不可分割或不可中断的(这意味着在定义的上下文中,原子性的 东西会完整运行不会发生其他事情);
  • 在考虑原子性时,经常第1件需要做的事就是定义上下文或范围,然后再考虑这些操作是否是原子性的;
  • 原子表示它在并发中是安全的;

内存访问同步

  • 临界区:程序中需要独自访问的部分
  • 通过lock和unlock可以进行内存访问同步(sync.Mutex)

死锁,活锁和饥饿

死锁

  1. 死锁程序是所有并发进程彼此等待的程序,如果没有外界的干预,这个程序将永远无法恢复

  2. 死锁的条件

    1. 相互排斥:并发的进程同时拥有资源的独占权
    2. 等待条件:并发进程必须同时拥有一个资源,并等待额外的资源
    3. 没有抢占:并发进程拥有的资源只能被该进程释放
    4. 循环等待:一个pi在等待其他pi,其他pi也在等待pi

活锁

  • 活锁是正在主动执行并发操作的程序,但是这些操作无法向前推进程序的状态
  • 两个及其以上的并发进程在没有协调的情况下防止死锁导致相互之间无法跳出,反复横跳

饥饿

  • 饥饿是在任何情况下,并发进程无法获得执行工作的所需的所有资源
  • 某个进程抢占了所有资源导致其他的进程无法进行

通讯顺序进程

并发与并行的区别

并发属于代码,并行属于一个运行的程序

并行意味着程序在任意时刻都是同时运行的

并发意味着程序在单位时间内是同时运行的

  1. 首先我们并没有编写并行的代码,只有我们希望可以并行执行的并发代码
  2. 另外,并行是我们程序运行时的属性,而不是我们代码
  3. 对我们所写的并发代码是否真的并行执行是毫不知情的。这只有在我们程序模型下的抽象层实现;
  • 并发原语、程序运行时、操作系统、操作系统所运行的平台、以及最终的cpu,这些抽象给与我们区分并发与并行

  • 并行是一个时间或者上下文的函数

    • 这里上下文定义为两个或者以上的操作被认为是并行的界限

CSP:Communicating Sequential Processes(通信顺序进程)

channel和传统锁的选择决策

  • 决策树

  1. 你想要转让数据的所有权吗?

    1. 并发程序的安全就是保证同时只有一个并发上下文拥有数据的所有权,所以创建一个带缓存channel是实现一个低成本的内存中的队列来解耦生产者和消费者,同时也可以使并发的代码相互组合
  2. 你是否在保护某个结构的内部状态?

    1. 这时通过内存访问同步能够定义结构的原子性范围,为调用者隐藏关于代码块的实现细节
  3. 你是否试图协调多个逻辑片段?

    1. channel比内存访问同步更具有组合性
  4. 这是一个对性能要求很高的临界区吗?

    1. 由于channel底层也是内存访问同步,所以当性能需要提升而这个临界区是个性能瓶颈的时候,可以吧channel改成mutex

go并发哲学:认为goroutine是没有使用成本且尽量使用channel

Go语言的并发组件

goroutine

  • goroutine是一个并发函数(不是并行的),通过go关键字触发

  • goroutine不是OS线程更不是绿色线程(由语言运行时管理的线程),而是协程

    • 协程是一种非抢占式的简单并发子goroutine(函数、闭包、方法),他们不能被中断而是允许暂停或重新进入
  • goroutine没有定义自己的暂停方法或再运行点,go会在阻塞的时候挂起他们,并在不阻塞后重新运行

  • goroutine和协程都是隐式并发结构,但并发不是协程的属性:必须同时托管多个协程,并给每个协程一个执行的机会,否则将不会并发

  • go运行时会对goroutine闭包引用的变量进行保护,将其内存转入堆中,以便于继续访问

//启动一个goroutine, 通过go+函数名启动
func main(){
    go func(){
        for i:=1;i<10;i++{
            time.sleep(time.Second);
        }    
    }
}
  1. func GOMAXPROCS
  • func GOMAXPROCS(n int) int用来查询 可以并发执行的goroutine的数目,n>1表示设置其值,n=0表示查询
  • 调整这个值会使你的程序更接近它所运行的硬件,但以抽象和长期性能为代价
runtime.GOMAXPROCS(n);

2. func Goexit

  • 结束当前的goroutine的运行,并调用当前goroutine已经注册的defer
  1. func Gosched
  • 放弃当前调度执行机会,把当前的goroutine放到队列等待下次调度

sync 包

WaitGroup

  • 当不关心并发操作结果时,WaitGroup是等待并发操作结束的好办法,相当与一个并发安全的计数器
var wg sync.WaitGroup

wg.Add(1)//表示开始一个goroutine

go func(){
    defer wg.Done() //每次done都减一
    fmt.println("xixi")
    time.Sleep(time.Second)
}

wg.Wait()//阻塞,直到goroutine全部退出

互斥锁和读写锁

  1. Mutex(互斥锁)
  • 可以使用 defer lock.Unlock()来确保在发生panic时也能关闭,防止出现死锁
var cnt int
var lock sync.Mutex//声明

//两goroutine有着共享内存区域,通过互斥锁来同步访问
go func(){
    lock.Lock()
    defer lock.Unlock()
    cnt++
}

go func(){
    lock.Lock()
    defer lock.Unlock()
    cnt--
}

2. RWMutex(读写锁)

  • 为了保证数据的安全,它规定了当有人还在读取数据(即读锁占用)时,不允计有人更新这个数据(即写锁会阻塞)

  • 为了保证程序的效率,多个人(线程)读取数据(拥有读锁)时,互不影响不会造成阻塞,它不会像 Mutex 那样只允许有一个人(线程)读取同一个数据。

  • 读锁:调用RLock开启,URnlock来关闭

  • 写锁:Lock开启,Unlock关闭

Cond(条件变量)

  • 一个goroutine的集合点,等待或发布一个event

    • 一个event是两个或以上的goroutine之间的任意信号
  • fund (c *Cond) Wait(){...}

    • 等待条件达成
    • 调用Wait()会调用Unolck()
    • 退出Wait () 会调用Lock()
  • func (c *Cond) Broadcast(){...}

    • 向所有goroutine广播信号
//队列保证每次添加一个元素就弹出一个
c:=sync.NewCond(&sync,Mutex())

queue:=make([]interface,0,10);

remove:=func(delay time.Duration){
    time.sleep(delay)
    
    c.L.Lock()
    
    queue=queue[1:]
    fmt.println("q.pop()")
    
    c.L.Unlock()
    c.Signal()//向一个等待最久的goroutine发送信号
}

for i:=0;i<10;i++{
    c.L.Lock()
    for len(queue)==2{
        c.wait()
    }
    fmt.println("q.push()")
    queue=append(queue,struct{}{})
    
    go remove(1*time.Second())
    
    c.L.UnLock()
}
  1. Once
  • 确保即使在不同的goroutine里 也只会调用Do()方法一次
var cnt int

add:=func(){
    cnt++
}
var once sync.Once
var wg sync.WaitGroup

wg.add(100)

for i:=0;i<100;i++{
   go func(){
       defer wg.Donc()
       once.Do(add)
   }()
}

fmt.println(cnt) //cnt=1
  • Do 发生死锁
var onceA,onceB sync.Once

var initB func()
initA:=func(){onceB.Do(initB)}
initB=func(){onceA.Do(initA)}//死锁

onceA.Do(initA)

Pool(池)

  • Pool模式是一种创建和提供可供使用的固定数量实例或pool实例的方法
  • Pool 调用Get 方法会先检查池中是否会有实例返回给调用者,如果没有,调用new方法创建一个新的实例,完成后调用put方法归还给池
var cnt int
mypool:=$sync.Pool{
    New:=func()interface{}{
        cnt++
        return struct{}{}
    }
}
mypool.Get()
k:=mypool.Get()
mypool.put(k)
mypool.Get()

//cnt=2

channel

  • 只读<-chan int
  • 只写 chan <- int
  • 如果一个缓冲channel是空的,并且下游由接收的时候会直接跳过缓冲区直接由发射到接收
  • 确保channel一定初始化否则任何操作都会painc
  • 缓冲通道和消息队列相似,有削峰和增大吞吐量的功能
 var data chan interface{}
 data=make(chan interface{})
 
 var data <-chan interface{}
 data=make(<-chan interface{})
 
 var data chan <-interface{}
 data=make(chan <-interface{})
操作ReadwritecloseChannel状态nil打开且非空打开且空关闭只写nil打开但填满打开且不满关闭只读nil打开且非空打开且空关闭只读结果阻塞输出,true阻塞<默认值>,false编译错误阻塞阻塞写入值panie编译错误panie关闭channel关闭Panie编译错误

拥有channel的goroutine应具备

  1. 实例化channel
  2. 执行写的操作,或将所有权传递给另一个goroutine
  3. 关闭channel
  4. 封装前三件事,并通过一个只读channel将它们暴露出来

select

  • 在select的case语句集合中,每一个都有一个被执行的机会
  • 没有测试顺序,如果没有满足任何的条件,执行也不会失败
var c1,c2 <-chan interface{}
var c3 chan<-interface{};

select {
    case <-c1:
    //
    case <-c2:
    //
    case c3<-struct{}{}:
    //
    default:
    //
}

Go并发模式

for-select 循环

  • 向channel发送迭代变量
  • 循环等待停止
for{
    select {
        case <-done:
            return 
        default:
    }
}

防止goroutine泄漏

  • goroutine被终止

    • 当他完成他的工作
    • 因为不可恢复的错误,他不能继续工作
    • 当他被告知需要终止工作
  • 如果goroutine负责创建goroutine,它也负责确保它可以停止grotinue

  • goroutine取消的三个方面

    • goroutine的父goroutine可能想要取消它
    • 一个goroutine可能想要取消它的子goroutine
    • goroutine的任何阻塞操作都必须是可抢占的,以便它可以被取消

or-done

  • 通过递归和goroutine的方式来合并一个done channel

    • 对传入的channel slice的【3:】进行递归,并返回一个channel来确定【3:】的状态,通过不断切片直到切片的len小于3
    • 当channel slice len==0 return nil
    • 当channel slice len ==1 return 1
    • 当 channel slice len ==2 直接确定两个channel的状态,不需要再多一个 done channel 来判断两个channel的状态
    • 当一个channel关闭后,会使得当前的递归节点返回一个已关闭的 done channel 并且上一个节点会对 返回的channel进行判断,最后会在递归返回到or 复合channel 使得所有阻塞的gorouline返回;
 var or func(channels ...<-chan interface)<-chan interface{}
     
     or=func(channels,...<-chan interface)<-chan interface{
         switch(len(channels)){
             case 0: 
                return nil
             case 1:
                return channels[0]
         }
         orDone :=make(chan interface{})
         
         go func(){
             
             defer close(ordone)
             
             switch len(channels){
                case 2:
                    select{
                        case <-channels[0]:
                        case <-channels[1]:
                    }
                default:
                    select{
                        case <-channels[0]:
                        case <-channels[1]:
                        case <-channels[2]:
                        case <-or(append(channels[3:],orDone)...):
                    }
             }
         }()
         return orDone
     }

错误处理

  • 并发进程应该把他们的错误发送到你的程序的另一部分,它有你程序状态的网站信息,并且可以做出更明智的决定做什么。
  • 在构建goroutine返回值时,应将错误视为一等公民。如果你的goroutine可能产生错误,那么这些错误应该与你的结果类型紧密结合,并且通过相同的通信线传递,就像常规的同步函数。

pipeline(管道)

  • 一系列将数据输入,执行操作并将结果数据传回的系统

    • 这些操作都是pipeline的一个stage

      • 一个stage消耗和返回相同的类型
      • 一个stage必须用语言来表示,以便它可以被传递
  • 构建pipeline的最佳实践

    • 生成器:将一组离散值转化为一个channel上的数据值

      • 有两点是可抢占的

        • 创建不是瞬时的离散值
        • 在其channel上发送离散值
    • pipeline 当覆盖的channel被抢占的时候会关闭,会导致最后的stage也被抢占

    • 一般pipeline 上的限制因素将会是你的生成器,或者是计算密集型的一个stage


    get:=func(done <-chan interface{},nums ...int)<-chan int{
        c:=make(chan int)
        
        go func(){
            defer close(c)
            for _,i:=range nums{
                select{
                    case <-done:
                        return
                    case c <-i:
                }
            }
        }()
        
        return c
    }
    
    mut:=func(done <-chan interface{},
    nums <-chan int,w int)<-chan int{
        c:=make(chan int)
        
        go func(){
            defer close(c)
            for i:=range nums{
                select{
                    case <-done:
                        return
                    case c<-i*w:
                }
            }
        }()
        
        return c
    }
    
    add:=func(done <-chan interface{},
    nums <-chan int,w int)<-chan int{
        c:=make(chan int)
        
        go func(){
            defer close(c)
            for i:=range nums{
                select{
                    case <-done:
                        return
                    case c<-i+w:
                }
            }
        }()
        
        return c
    }
    
    done:=make(chan interface{})
    defer close(done)
    
    nums:=get(done,1,2,3,4)
    
    ans:=add(done,mut(done,nums,2),1)
    
    for i:=range ans{
        fmt.Println(i)
    }

扇入扇出

  • 扇出:启动多个goroutine以处理来自pipeline的输入过程

  • 扇入:将多个结果组合到一个channel的输出过程

  • 在多个goroutrine上重用我们的pipeline的单个stage以试图并行化来自上游stage的pull

    • 当stage不依赖之前的stage计算的值
    • 这个stage的运行时间非常的长
  • 简单的开启多个goroutine来处理输入以及开启来处理多个channel的输出无法保证流的顺序

//扇入
numFinders:=runtime.NumCPU()//返回CPU核心数量

get:=func(done <-chan interface,k int)<-chan interface{}{
    ans:=make(chan interface)
    for i:=0;i<k;i++{
        ans<-struct{}{}
    }
    return ans
}

finders:=make([]<-chan interface{},numFinders)
for i:=0;i<numFinders;i++{
    finders[i]=get(done,10)//开启多个goroutine,组合到finders中
}


//扇出
fanin:=func(done <-chan interface{},nums...<-chan interface)
<-chan interface{
    var wg sync.WaitGroup
    ans:=make(chan interface)
    
    fan:=func(c <-chan interface{}){
        defer wg.Done()
        
        for i:=range c{
            select{
                case done:
                    return
                case ans<-i:
            }
        }
    }
    wg.Add(len(nums))
    
    for _,c:= range nums{
        go fan(c)
    }
    
    go func(){
        defer close(ans)
        wg.Wait()
    }
    
    return ans
}

or-done-channel

  • 对分散在系统的各个部分的channel,如果某个channel已经close掉,那么可能在for-select里可能一值读取该通道的零值,导致无法退出
ordone:=func(done ,c <-chan interface{})<-chan interface{
    ans:=make(chan interface{])
    
    go func(){
        defer close(ans)
        
        for{
            select{
                case <-done:
                    return
                case v,ok:=<-c:
                    if ok==fasle{
                        return
                    }
                    select{
                        case ans<-v:
                        case<-done:
                    }
            }
        }
        
    }()
    return ans   
}

for val:=range orDone(done,mychan){
}

tree-channel

  • 将一个channel分成两个相同的channel
tree:=func(
    done <-chan interface{}
    in <-chan interface{}
)(_,_ <-chan interface{}){
    out1:=make(chan interface{})
    out2:=make(chan interface{})
    
    go func(){
       defer close(out1)
       defer close(out2)
       
       for val:=range orDone(done,in){
           var out1,out2=out1,out2
           
           for i:=0;i<2;i++{
               select{
                   case <-done:
                   case out1<-val
                      out1=nil
                   case out2<-val
                      out2=nil
               }
           }
       }
    }()
}

桥接channel模式

  • 将充满channel的channel拆解成一个简单的channel
bridge:=func(
    done <-chan interface{},
    chanSteam <-chan <-chan interface{}
)<-chan interface{}{
    val=make(chan interface{})
    
    go func(){
        defer close(val)
        for{
            var steam <-chan interface{}
            select{
                case maybe,ok:=<-chanSteam:
                    if ok==false{
                        return
                    }
                    steam=maybe
                case <-done:
                    return 
            }
            for v:=range orDone(done,steam){
                select{
                    case <done:
                    case val<-v:
                }
            }
        }
    }()
    return val
}

队列排队

  • 带缓冲的channel就是一种队列

  • 队列几乎不会加速程序的总运行时间,它只会让程序的行为有所不同

  • 队列真正的用途将stage分离,以便一个stage的运行时间不会影响另一个stage的运行时间。

  • 排队可以提高系统整体性能的情况:

    • 如果在一个stage批处理请求节省时间

    • 如果stage中的延迟产生反馈回路进入系统。

      • 一个stage的延迟导致管道中接受到了更多的输入,他可能导致上游的崩溃
      • 通过在管道的入口引入队列,将请求滞后为代价来打破反馈循环
  • 排队模式:

    • 在你的管道入口处
    • 在这个stage,批量操作会带来更高的效率
  • 利特尔法则(只可应用在稳定的系统)

  • 在管道中,一个稳定的系统指入口的速率与负载的速率相等

  • 增加了队列,其实就是增加了L,所以队列对系统时间无影响

  • 通过公式可以计算队列的大小

  • 利特尔法则无法预知处理请求的失败

    • 由于某种原因导致管道混乱,将队列的所有请求丢失
    • 可以坚持大小为0 的队列或者转移到一个持久化的队列(随时读取的地方)
  • 队列由于其复杂性建议其作为优化的最后一个手段