Golang-并发编程原理解析

go并发编程

一.背景知识介绍

1.进程和线程

  • 进程是程序在操作系统中一次执行的过程,系统进行资源分配和调度的基本单位
  • 线程是进程的一个执行实体,是CPU调度和分派的基本单位,它是比进程更小的能独立运行的基本单位
  • 一个进程可以创建和撤销多个线程,同一个进程中的多个线程之间可以并发执行

2.并发和并行

  • 多线程程序在单核CPU上运行,就是并发
  • 多线程程序在多核CPU上运行,就是并行
  • 并发不是并行,并发主要是由切换时间片来实现同时运行,并行则是直接利用多核实现多线程的运行,go可以设置使用的核数,以发挥多核计算机的能力

3.协程和线程

  • 协程:独立的栈空间,共享堆空间,调度由用户自己控制,本质上有点类似于用户级线程,这些用户级线程的调度也是自己实现的
  • 线程,一个线程上可以跑多个协程,协程是轻量级的线程

4.goroutine

  • 每个goroutine实例只有4-5KB的内存占用(可伸缩),和由于实现机制而大幅度减少的创建和销毁开销是go高并发的根本原因
  • goroutine 奉行通过通信来共享内存,而不是通过共享内存来通信

二.Channel

  • Go语言的并发模型是CSP,提倡通过通信来进行内存共享,而不是通过共享内存来实现通信
  • goroutine是Go程序并发的实体,channel就是他们之间的连接,channel是可以让一个goroutine发送特定值到另一个goroutine的通信机制
  • Go语言中的channel是一种特殊的类型,channel像是一个传送带或者队列,总是遵循FIFO先入先出的原则,保证收发数据的顺序
  • 每一个Channel都有其具体的传输数据类型,也就是说声明的需要为其指定元素类型
  • 声明的Channel需要make初始化之后才能使用
  • 声明并初始化channel
    ch := make(chan int)
    

1.channle的操作

channel有三种操作,接收,发送,关闭。发送和接收都使用 <- 符号

  • 接收
x := <- ch // 从ch中接收值并赋值给变量x
  • 发送
ch <- 10 // 把10发送到ch中
  • 关闭
close(ch)

注意事项:

  • 关于关闭通道,只有在需要通知接收方的goroutine,所有的数据都已经发送完毕的时候,才需要关闭通道,也就是说,关闭通道,可以告诉接收方的goroutine,所有的数据都已经发送完毕了
  • 通道是可以被GC回收的,关闭通道不是必须的,不像文件那样,在结束操作后必须关闭文件
  • 对一个关闭的通道,再发送数据,就会导致panic
  • 对一个关闭的通道进行接收会一直获取值,直到通道为空为止
  • 对一个关闭的,并且没有值的通道,进行接收操作,会得到对应类型的零值
  • 关闭一个已经关闭的通道会导致panic

2.无缓冲的Channel

  • 无缓冲通道,又叫做阻塞通道

  • 无缓冲通道只有在有人接收值的时候才能发送值,否则就会一直阻塞住,同样的,只有在有人发送值的时候,才能接收值
func main() {
    ch := make(chan int)
    ch <- 10
    fmt.Println("发送成功")
    
    // fatal error: all goroutines are asleep - deadlock!
}
func recv(c chan int) {
    ret := <-c
    fmt.Println("接收成功", ret)
}
func main() {
    ch := make(chan int)
    go recv(ch) // 启用goroutine从通道接收值
    ch <- 10
    fmt.Println("发送成功")
}
  • 使用无缓冲通道进行通信将导致发送和接收的goroutine同步化,因此,无缓冲通道也被称为同步通道

3.有缓冲的Channel

  • 在通道使用make函数初始化的时候可以指定缓冲容量
ch := make(chan int, 10) // 创建一个容量为10的有缓冲区通道

  • 只要通道的容量大于0,那么该通道就是有缓冲通道
  • 通道的容量表示通道中能最多存放元素的数量
  • 通过len函数获取当前通道内元素数量,通过cap函数获取通道的容量

4.单向channel

  • 限制通道在函数中只能发送或者接收
  • chan <- int是一个只能发送的通道,可以发送但是不能接收;
  • <-chan int是一个只能接收的通道,可以接收但是不能发送。
  • 在函数传参以及任何赋值操作中,将双向通道转为单向通道是可以的,但反过来不行
func counter(out chan<- int) {
    for i := 0; i < 100; i++ {
        out <- i
    }
    close(out)
}

func squarer(out chan<- int, in <-chan int) {
    for i := range in {
        out <- i * i
    }
    close(out)
}
func printer(in <-chan int) {
    for i := range in {
        fmt.Println(i)
    }
}

func main() {
    ch1 := make(chan int)
    ch2 := make(chan int)
    go counter(ch1)
    go squarer(ch2, ch1)
    printer(ch2)
}

5.优雅的从通道循环取值

  • 通过通道发送有限的数据时,可以通过close函数关闭通道来告知接收该通道值的goroutine停止等待
  • 当通道关闭时,再往该通道发送值会导致panic,并且从该通道接收到值,全部接收完了之后再接收的话,会接收到类型零值
  • 那我们应该如何判断通道被关闭了呢?有下面两种方法可供参考
func main() {
    ch1 := make(chan int)
    ch2 := make(chan int)
    // 开启goroutine将0~100的数发送到ch1中
    go func() {
        for i := 0; i < 100; i++ {
            ch1 <- i
        }
        close(ch1)
    }()
    // 开启goroutine从ch1中接收值,并将该值的平方发送到ch2中
    go func() {
        for {
            i, ok := <-ch1 // 通道关闭后再取值ok=false
            if !ok {
                break
            }
            ch2 <- i * i
        }
        close(ch2)
    }()
    // 在主goroutine中从ch2中接收值打印
    for i := range ch2 { // 通道关闭后会退出for range循环
        fmt.Println(i)
    }
}

6.channel异常情况总结

三.select多路复用

  • 在某些场景下,我们需要从多个通道接收数据,通道在接收数据时,如果没有数据可以接收得阻塞住,等待数据过来,为了应对这种场景,GO内置了select关键字,可以同时响应多个通道的操作
  • go select的思想来源于网络IO模型中的select,本质上也是IO多路复用,只不过这里的IO是基于channel而不是基于网络
  • select的使用类似switch,有一系列case和default,每个case对应一个通道的接收或发送过程
    select {
    case <-chan1:
       // 如果chan1成功读到数据,则进行该case处理语句
    case chan2 <- 1:
       // 如果成功向chan2写入数据,则进行该case处理语句
    default:
       // 如果上面都没有成功,则进入default处理流程
    }
  • select会一直等待,直到某个case的通信操作完成时,就会执行case分支对应的语句
  • select可以同时监听多个channel,直到其中一个channel 就绪,如果多个channel同时就绪,则随机选择一个执行
  • select可以用来判断管道是否已满,比如仅有一个case和default,该case是往管道写,当管道满了之后,该case会失败,因仅有一个case,所以会走default,那就可以在default中通知该通道已满
package main

import (
	"fmt"
	"time"
)

// 判断管道有没有存满
func main() {
	// 创建管道
	output1 := make(chan string, 10)
	// 子协程写数据
	go write(output1)
	// 取数据
	for s := range output1 {
		fmt.Println("res:", s)
		time.Sleep(time.Second)
	}
}

func write(ch chan string) {
	for {
		select {
		// 写数据
		case ch <- "hello":
			fmt.Println("write hello")
		default:  // 因为通道满了,往执行不了写数据了,就会走default
			fmt.Println("channel full")
		}
		time.Sleep(time.Millisecond * 500)
	}
}

四.goroutine池

  • 本质是生产者消费者模型
  • 可以有效的控制goroutine数量,防止暴涨
  • 需求:
    • 计算一个数字的各个数位之和,例如数字123,结果是1+2+3=6
    • 随机生成数字进行计算
package main

import (
    "fmt"
    "math/rand"
)

type Job struct {
    // id
    Id int
    // 需要计算的随机数
    RandNum int
}

type Result struct {
    // 这里必须传对象实例
    job *Job
    // 求和
    sum int
}

func main() {
    // 需要2个管道
    // 1.job管道
    jobChan := make(chan *Job, 128)
    // 2.结果管道
    resultChan := make(chan *Result, 128)
    // 3.创建工作池
    createPool(64, jobChan, resultChan)
    // 4.开个打印的协程
    go func(resultChan chan *Result) {
        // 遍历结果管道打印
        for result := range resultChan {
            fmt.Printf("job id:%v randnum:%v result:%d\n", result.job.Id,
                result.job.RandNum, result.sum)
        }
    }(resultChan)
    var id int
    // 循环创建job,输入到管道
    for {
        id++
        // 生成随机数
        r_num := rand.Int()
        job := &Job{
            Id:      id,
            RandNum: r_num,
        }
        jobChan <- job
    }
}

// 创建工作池
// 参数1:开几个协程
func createPool(num int, jobChan chan *Job, resultChan chan *Result) {
    // 根据开协程个数,去跑运行
    for i := 0; i < num; i++ {
        go func(jobChan chan *Job, resultChan chan *Result) {
            // 执行运算
            // 遍历job管道所有数据,进行相加
            for job := range jobChan {
                // 随机数接过来
                r_num := job.RandNum
                // 随机数每一位相加
                // 定义返回值
                var sum int
                for r_num != 0 {
                    tmp := r_num % 10
                    sum += tmp
                    r_num /= 10
                }
                // 想要的结果是Result
                r := &Result{
                    job: job,
                    sum: sum,
                }
                //运算结果扔到管道
                resultChan <- r
            }
        }(jobChan, resultChan)
    }
}

五.并发安全和锁

  • 当多个goroutine操作同一个临界区的资源时,可能会有数据竞态问题,导致非预期结果

样例如下,我们启动两个goroutine去累加x的值,但这两个goroutine访问x时会存在数据竞争

var x int64
var wg sync.WaitGroup

func add() {
    for i := 0; i < 5000; i++ {
        x = x + 1
    }
    wg.Done()
}
func main() {
    wg.Add(2)
    go add()
    go add()
    wg.Wait()
    fmt.Println(x)
}

1.互斥锁

  • 互斥锁是一种常用的控制共享资源访问的方法
  • Go语言中使用sync包的Mutex类型来实现互斥锁
  • 使用互斥锁可以保证同一时间有且只有一个goroutine进入临界区,其他goroutine则在等待锁
  • 当互斥锁释放后,等待的goroutine才可以获取锁进入临界区
  • 多个goroutine同时等待一个锁时,唤醒的策略是随机的
var x int64
var wg sync.WaitGroup
var lock sync.Mutex

func add() {
    for i := 0; i < 5000; i++ {
        lock.Lock() // 加锁
        x = x + 1
        lock.Unlock() // 解锁
    }
    wg.Done()
}
func main() {
    wg.Add(2)
    go add()
    go add()
    wg.Wait()
    fmt.Println(x)
}

2.读写锁

  • 很多场景下是读多写少的,当我们并发去读取一个资源不涉及资源修改时,完全没有必要使用互斥锁,因为互斥锁是完全互斥的
  • 读多写少的情况下,使用读写锁是一个更好的选择
  • Go语言中使用sync包的RWMutex来实现读写锁
  • 读写锁分为两种:读锁和写锁
  • 当一个goroutine获取读锁之后,其他的goroutine如果是获取读锁则会获得读锁,如果是获取写锁就会等待
  • 当一个goroutine获取写锁之后,其他的goroutine无论是获取读锁还是获取写锁,都会等待
  • 读写锁非常适合读多写少的场景,如果读和写差别不大,则读写锁的优势就发挥不出来

样例如下:

var (
    x      int64
    wg     sync.WaitGroup
    lock   sync.Mutex
    rwlock sync.RWMutex
)

func write() {
    // lock.Lock()   // 加互斥锁
    rwlock.Lock() // 加写锁
    x = x + 1
    time.Sleep(10 * time.Millisecond) // 假设读操作耗时10毫秒
    rwlock.Unlock()                   // 解写锁
    // lock.Unlock()                     // 解互斥锁
    wg.Done()
}

func read() {
    // lock.Lock()                  // 加互斥锁
    rwlock.RLock()               // 加读锁
    time.Sleep(time.Millisecond) // 假设读操作耗时1毫秒
    rwlock.RUnlock()             // 解读锁
    // lock.Unlock()                // 解互斥锁
    wg.Done()
}

func main() {
    start := time.Now()
    for i := 0; i < 10; i++ {
        wg.Add(1)
        go write()
    }

    for i := 0; i < 1000; i++ {
        wg.Add(1)
        go read()
    }

    wg.Wait()
    end := time.Now()
    fmt.Println(end.Sub(start))
}

3.sync.Map

  • Go语言中内置的map并不是并发安全的

样例如下:

var m = make(map[string]int)

func get(key string) int {
	return m[key]
}

func set(key string, value int) {
	m[key] = value
}

func main() {
	wg := sync.WaitGroup{}
	for i := 0; i < 20; i++ {
		wg.Add(1)
		go func(n int) {
			key := strconv.Itoa(n)
			set(key, n)
			fmt.Printf("k=:%v,v:=%v\n", key, get(key))
			wg.Done()
		}(i)
	}
	wg.Wait()
	
	// fatal error: concurrent map writes
}
  • Go语言的sync包中提供了一个开箱即用的并发安全版Map,sync.Map,开箱即用表示不用像内置的Map一样使用make函数初始化就能用,同时sync.Map内置了诸如Store,Load,LoadOrStore,Delete,Range等操作方法。
var m = sync.Map{}

func main() {
    wg := sync.WaitGroup{}
    for i := 0; i < 20; i++ {
        wg.Add(1)
        go func(n int) {
            key := strconv.Itoa(n)
            m.Store(key, n)
            value, _ := m.Load(key)
            fmt.Printf("k=:%v,v:=%v\n", key, value)
            wg.Done()
        }(i)
    }
    wg.Wait()
}

六.并发同步

  • Go语言中使用sync包的WaitGroup来实现并发任务的同步

1.sync.WaitGroup

sync.WaitGroup有以下三种方法:

  • (wg * WaitGroup) Add(delta int) : 计数器 + delta
  • (wg *WaitGroup) Done() : 计数器-1
  • (wg *WaitGroup) Wait() : 阻塞知道计数器变为0

sync.WaitGroup内部维护着一个计数器,计数器的值可以增加和减少,例如当我们启动了N个并发任务时,就将计数器增加N,每个任务通过调用Done方法将计数器减1,通过调用Wait()来等待并发任务执行完,当计数器的值为0时间,表示所有并发任务都已经完成

var wg sync.WaitGroup

func hello() {
    defer wg.Done()
    fmt.Println("Hello Goroutine!")
}
func main() {
    wg.Add(1)
    go hello() // 启动另外一个goroutine去执行hello函数
    fmt.Println("main goroutine done!")
    wg.Wait()
}

2.sync.Once

  • 很多场景下,我们需要确保某些操作在高并发时只执行一次,例如只加载一次配置文件
  • Go语言中的sync包提供了一个针对只执行一次场景的解决方案-sync.Once
  • sync.Once只有一个Do方法,Do(f func())

样例如下:

  • 延迟一个开销很大的初始化操作到真正用到它的时候再执行
var icons map[string]image.Image

var loadIconsOnce sync.Once

func loadIcons() {
    icons = map[string]image.Image{
        "left":  loadIcon("left.png"),
        "up":    loadIcon("up.png"),
        "right": loadIcon("right.png"),
        "down":  loadIcon("down.png"),
    }
}

// Icon 是并发安全的
func Icon(name string) image.Image {
    loadIconsOnce.Do(loadIcons)
    return icons[name]
}

sync.Once内部包含一个互斥锁和一个布尔值,互斥锁保证布尔值和数据的安全,而布尔值用来记录初始化是否完成,这样设计就能保证初始化操作的时候是并发安全的,并且初始化操作也不会执行多次

七.原子操作

  • 代码中加锁操作因为设计到内核态的上下文切换,耗时比较高,代价也大
  • 针对基本数据类型我们可以使用原子操作来保证并发安全
  • Go语言中原子操作由内置的标准库sync/atomic提供

样例如下:

  • 比较互斥锁和原子操作的性能
var x int64
var l sync.Mutex
var wg sync.WaitGroup

// 普通版加函数
func add() {
    // x = x + 1
    x++ // 等价于上面的操作
    wg.Done()
}

// 互斥锁版加函数
func mutexAdd() {
    l.Lock()
    x++
    l.Unlock()
    wg.Done()
}

// 原子操作版加函数
func atomicAdd() {
    atomic.AddInt64(&x, 1)
    wg.Done()
}

func main() {
    start := time.Now()
    for i := 0; i < 10000; i++ {
        wg.Add(1)
        // go add()       // 普通版add函数 不是并发安全的
        // go mutexAdd()  // 加锁版add函数 是并发安全的,但是加锁性能开销大
        go atomicAdd() // 原子操作版add函数 是并发安全,性能优于加锁版
    }
    wg.Wait()
    end := time.Now()
    fmt.Println(x)
    fmt.Println(end.Sub(start))
}
  • atomic包提供了底层的原子级内存操作,对于同步算法的实现很有帮助,但是除了某些特殊的底层应用,使用通道或者sync包实现同步更好!

八.总结

Go语言的并发模型是CSP,提倡通过通信来进行内存共享,而不是通过共享内存来实现通信

posted @ 2021-12-31 11:41  西*风  阅读(354)  评论(0编辑  收藏  举报