Go--并发编程

摘抄(有删改):https://www.topgoer.cn/docs/golang/chapter09-1

一、并发介绍

1.1 进程和线程

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

1.2 并发和并行

  • 多线程程序在一个核的cpu上运行,就是并发。
  • 多线程程序在多个核的cpu上运行,就是并行。

注:并发不是并行,并发主要由切换时间片来实现”同时”运行,并行则是直接利用多核实现多线程的运行

  go可以设置使用核数,以发挥多核计算机的能力,Go1.5版本之前,默认使用的是单核心执行。Go1.5版本之后,默认使用全部的CPU逻辑核心数。

package main

import (
    "fmt"
    "runtime"
)

func main() {
    //查看本地的cpu核数
    fmt.Println("可用核心数为:", runtime.NumCPU())
    //使用GOMAXPROCS()设置运行核心数,会返回之前的核数
    n := runtime.GOMAXPROCS(1)
    fmt.Println("之前设置的cpu核数为:", n)
}

 

1.3 协程和线程

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

 

二、goroutine

goroutine 是由官方实现的超级”线程池”,每个实例 4~5KB 的栈内存占用和由于实现机制而大幅减少的创建和销毁开销是go高并发的根本原因。

goroutine 奉行通过通信来共享内存,而不是共享内存来通信。

goroutine是由Go的运行时(runtime)调度和管理的,Go程序会智能地将 goroutine 中的任务合理地分配给每个CPU,go在语言层面已经内置了调度和上下文切换的机制

 

2.1 使用,启动单个goroutine

使用:在调用函数的时候在前面加上go关键字,就可以为一个函数创建一个goroutine;一个goroutine必定对应一个函数,可以创建多个goroutine去执行相同的函数。

启动:

package main

import (
    "fmt"
    "time"
)

func hello() {
    fmt.Println("Hello world!")
}

func main() {
    //函数前加go,启动一个goroutine执行
    go hello()
    fmt.Println("hello!")
    //等待一段时间,因为当main()结束时,所有的goroutine都将结束,而创建新的goroutine需要一定的时间,所以等待一段时间等goroutine执行完再结束main()
    time.Sleep(time.Second)
}

输出结果:

hello!
Hello world!

 

2.2 启动多个goroutine

package main

import (
    "fmt"
    "sync"
)

//使用sync.WaitGroup来实现goroutine的同步,后文介绍
var wg sync.WaitGroup //创建同步等待组对象

func hello(i int) {
    defer wg.Done() //goroutine结束后,计数器-1
    fmt.Println("Hello Goroutine!", i)
}
func main() {

    for i := 0; i < 10; i++ {
        wg.Add(1) //启动一个goroutine,计数器+1
        go hello(i)
    }
    wg.Wait() //等待所有登记的goroutine都结束
}

多次执行上面的代码,会发现每次打印的数字的顺序都不一致。这是因为10个goroutine是并发执行的,而goroutine的调度是随机的。

 

2.3 可增长的栈

OS线程(操作系统线程)一般都有固定的栈内存(通常为2MB),一个goroutine的栈在其生命周期开始时只有很小的栈(典型情况下2KB),goroutine的栈不是固定的,他可以按需增大和缩小,goroutine的栈大小限制可以达到1GB,虽然极少会用到这个大。所以在Go语言中一次创建十万左右的goroutine也是可以的。

 

2.4 goroutine调度

GPM是Go语言运行时(runtime)层面的实现,是go语言自己实现的一套调度系统。区别于操作系统调度OS线程。

  • G:就是goroutine的,里面除了存放本goroutine信息外 还有与所在P的绑定等信息
  • P:管理着一组goroutine队列,P里面会存储当前goroutine运行的上下文环境(函数指针,堆栈地址及地址边界),P会对自己管理的goroutine队列做一些调度(比如把占用CPU时间较长的goroutine暂停、运行后续的goroutine等等)当自己的队列消费完了就去全局队列里取,如果全局队列里也消费完了会去其他P的队列里抢任务。
  • M:M(machine)是Go运行时(runtime)对操作系统内核线程的虚拟, M与内核线程一般是一一映射的关系, 一个groutine最终是要放到M上执行的

另:

  • P与M一般也是一一对应的: P管理着一组G挂载在M上运行。当一个G长久阻塞在一个M上时,runtime会新建一个M,阻塞G所在的P会把其他的G 挂载在新建的M上。当旧的G阻塞完成或者认为其已经死掉时 回收旧的M。
  • P的个数是通过runtime.GOMAXPROCS设定(最大256),Go1.5版本之后默认为物理线程数。 在并发量大的时候会增加一些P和M,但不会太多,切换太频繁的话得不偿失。

 

2.5 调度性能

单从线程调度讲,Go语言相比起其他语言的优势在于OS线程是由OS内核来调度的,goroutine则是由Go运行时(runtime)自己的调度器调度的,这个调度器使用一个称为m:n调度的技术(复用/调度m个goroutine到n个OS线程)。

其一大特点是goroutine的调度是在用户态下完成的, 不涉及内核态与用户态之间的频繁切换,包括内存的分配与释放,都是在用户态维护着一块大的内存池, 不直接调用系统的malloc函数(除非内存池需要改变),成本比调度OS线程低很多。

另一方面充分利用了多核的硬件资源,近似的把若干goroutine均分在物理线程上, 再加上本身goroutine的超轻量,以上种种保证了go调度方面的性能。

 

三、runtime包

3.1 runtime.Gosched()

让出CPU时间片,重新等待安排任务

package main

import (
    "fmt"
    "runtime"
)

func main() {
    go func(s string) {
        for i := 0; i < 2; i++ {
            fmt.Println(s)
        }
    }("world")
    // 主协程
    for i := 0; i < 2; i++ {
        // 切一下,再次分配任务
        runtime.Gosched()
        fmt.Println("hello")
    }
}

输出结果:

world
world
hello
hello

 

3.2 runtime.Goexit()

退出当前协程

package main

import (
    "fmt"
    "runtime"
)

func main() {
    go func() {
        defer fmt.Println("A.defer")
        func() {
            defer fmt.Println("B.defer")
            // 结束协程
            runtime.Goexit()
            defer fmt.Println("C.defer")
            fmt.Println("B")
        }()
        fmt.Println("A")
    }()
    for {
    }
}

输出结果:

B.defer
A.defer

 

3.3 runtime.GOMAXPROCS

Go运行时的调度器使用GOMAXPROCS参数来确定需要使用多少个OS线程来同时执行Go代码,默认值是机器上的CPU核心数。如本文1.2所述

Go语言中的操作系统线程和goroutine的关系:

  • 一个操作系统线程对应用户态多个goroutine
  • go程序可以同时使用多个操作系统线程
  • goroutine和OS线程是多对多的关系,即m:n

 

四、channel

单纯地将函数并发执行是没有意义的。函数与函数间需要交换数据才能体现并发执行函数的意义

虽然可以使用共享内存进行数据交换,但是共享内存在不同的goroutine中容易发生竞态问题。为了保证数据交换的正确性,必须使用互斥量对内存进行加锁,这种做法势必造成性能问题。

Go语言的并发模型是CSP(Communicating Sequential Processes),提倡通过通信共享内存而不是通过共享内存而实现通信。

如果说goroutine是Go程序并发的执行体,channel就是它们之间的连接。channel是可以让一个goroutine发送特定值到另一个goroutine的通信机制

Go 语言中的通道(channel)是一种特殊的类型。通道像一个传送带或者队列,总是遵循先入先出(First In First Out)的规则,保证收发数据的顺序。每一个通道都是一个具体类型的导管,也就是声明channel的时候需要为其指定元素类型。

 

4.1 channel类型

channel是一种类型,一种引用类型。声明通道类型的格式如下:

    var 变量 chan 元素类型

//例:
    var ch1 chan int    // 声明一个传递整型的通道
    var ch2 chan bool   // 声明一个传递布尔型的通道
    var ch3 chan []int  // 声明一个传递int切片的通道

 

4.2 创建channel

通道是引用类型,通道类型的空值是nil

var ch chan int
fmt.Println(ch) // <nil>

声明的通道后需要使用make函数初始化之后才能使用

make(chan 元素类型, [缓冲大小])      //缓冲大小可选

//例:
ch4 := make(chan int)
ch5 := make(chan bool)
ch6 := make(chan []int)  

 

4.3 channel操作

通道有发送(send)、接收(receive)和关闭(close)三种操作,发送和接收都使用<-符号。

//定义一个通道
ch := make(chan int)

//将一个值发送到通道中
ch <- 10

//接收值
x := <- ch // 从ch中接收值并赋值给变量x
<-ch       // 从ch中接收值,忽略结果

//关闭,使用close()函数
close(ch)

关闭通道需注意:只有在通知接收方goroutine所有的数据都发送完毕的时候才需要关闭通道。通道是可以被垃圾回收机制回收的,它和关闭文件是不一样的,在结束操作之后关闭文件是必须要做的,但关闭通道不是必须的

通道关闭后的特点:

  • 对一个关闭的通道再发送值就会导致panic
  • 对一个关闭的通道进行接收会一直获取值直到通道为空
  • 对一个关闭的并且没有值的通道执行接收操作会得到对应类型的零值
  • 关闭一个已经关闭的通道会导致panic

 

4.4 无缓冲的通道

 

 无缓冲的通道又称为阻塞的通道

例:

func main() {
    ch := make(chan int)
    ch <- 10
    fmt.Println("发送成功")
}

上面的虽然能编译,但执行时会报错,因为使用 ch := make(chan int) 创建的是无缓冲的通道,无缓冲的通道只有在有人接收值的时候才能发送值,代码会阻塞在 ch <- 10 造成死锁。

可启用一个goroutine去接收值,或使用有缓存区的通道(见4.5)

func recv(c chan int) {
    ret := <-c
    fmt.Println("接收成功", ret)
}
func main() {
    ch := make(chan int)
    go recv(ch) // 启用goroutine从通道接收值
    ch <- 10
    fmt.Println("发送成功")
}

输出结果:

接收成功 10
发送成功

无缓冲通道上的发送操作会阻塞,直到另一个goroutine在该通道上执行接收操作,这时值才能发送成功,两个goroutine将继续执行。相反,如果接收操作先执行,接收方的goroutine将阻塞,直到另一个goroutine在该通道上发送一个值。

使用无缓冲通道进行通信将导致发送和接收的goroutine同步化。因此,无缓冲通道也被称为同步通道。

 

4.5 有缓冲的通道

 

 可以在使用make函数初始化通道的时候为其指定通道的容量,只要通道的容量大于零,那么该通道就是有缓冲的通道,通道的容量表示通道中能存放元素的数量

可以使用内置的len函数获取通道内元素的数量,使用cap函数获取通道的容量,但不常用

func main() {
    ch := make(chan int, 1) // 创建一个容量为1的有缓冲区通道
    ch <- 10
    fmt.Println("发送成功")
}

 

4.6 从通道中循环取值

常使用for range的方式判断通道是否关闭

// channel 练习
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)
    }
}

 

4.7 单向通道

在函数传参及任何赋值操作中将双向通道转换为单向通道是可以的,但反过来是不可以的

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)
}

 

4.8 常见异常

 

 

五、Sync

5.1 sync.WaitGroup

在代码中生硬的使用 time.Sleep 肯定是不合适的,Go语言中可以使用sync.WaitGroup来实现并发任务的同步。

方法名功能
(wg * WaitGroup) Add(delta int) 计数器+delta
(wg *WaitGroup) Done() 计数器-1
(wg *WaitGroup) Wait() 阻塞直到计数器变为0

sync.WaitGroup内部维护着一个计数器,计数器的值可以增加和减少。

例如当启动了N 个并发任务时,就将计数器值增加N,每个任务完成时通过调用Done()方法将计数器减1,通过调用Wait()来等待并发任务执行完,当计数器值为0时,表示所有并发任务已经完成。

注意:sync.WaitGroup是一个结构体,传递的时候要传递指针

//创建同步等待组对象
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()
}

 

6.2 使用 sync.WaitGroup + channel 控制协程数量

下面代码执行的结果为,第一秒输出3个值,等待一秒后,再输出3个值,以此类推,直至程序结束。

package main

import (
    "log"
    "sync"
    "time"
)

func main() {
    //创建同步等待组对象
    var wg sync.WaitGroup
    //设置通道缓冲大小为3
    ch := make(chan struct{}, 3)
    for i := 0; i < 10; i++ {
        //发送空值到ch中,按顺序发送值,当通道内值达到3的时候,阻塞,无法进行下一步动作,等待下面的某个协程结束,接收一个值后,再继续发送值,进行for循环其他的动作,以此来保证并发协程数量最多为3个
        ch <- struct{}{}
        //设置计数器数量为1
        wg.Add(1)
        //开启一个goroutine
        go func(i int) {
            //函数执行完后,计数器-1
            defer wg.Done()
            log.Println(i)
            time.Sleep(time.Second)
            //接收ch值,忽略结果,接收值后通道内的值-1
            <-ch
        }(i)
    }
    //等待所有的goroutine结束
    wg.Wait()
}

 

六、并发安全和锁

有时候在Go代码中可能会存在多个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)
}

6.1 互斥锁

互斥锁是一种常用的控制共享资源访问的方法,它能够保证同时只有一个goroutine可以访问共享资源,Go语言中使用sync包的Mutex类型来实现互斥锁。 

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)
}

 

6.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))
}

 

posted @ 2022-12-15 11:04  心恩惠动  阅读(72)  评论(0编辑  收藏  举报