理解Go协程与并发

 

协程

Go语言里创建一个协程很简单,使用go关键字就可以让一个普通方法协程化:

Copy
  1. package main
  2. import (
  3. "fmt"
  4. "time"
  5. )
  6. func main(){
  7. fmt.Println("run in main coroutine.")
  8. for i:=0; i<10; i++ {
  9. go func(i int) {
  10. fmt.Printf("run in child coroutine %d.\n", i)
  11. }(i)
  12. }
  13. //防止子协程还没有结束主协程就退出了
  14. time.Sleep(time.Second * 1)
  15. }

下面这些概念可能不太好理解,需要慢慢理解。可以先跳过,回头再来看。

概念:

  1. 协程可以理解为纯用户态的线程,其通过协作而不是抢占来进行切换。相对于进程或者线程,协程所有的操作都可以在用户态完成,创建和切换的消耗更低。
  2. 一个进程内部可以运行多个线程,而每个线程又可以运行很多协程。线程要负责对协程进行调度,保证每个协程都有机会得到执行。当一个协程睡眠时,它要将线程的运行权让给其它的协程来运行,而不能持续霸占这个线程。同一个线程内部最多只会有一个协程正在运行。
  3. 协程可以简化为三个状态:运行态就绪态休眠态。同一个线程中最多只会存在一个处于运行态的协程。就绪态协程是指那些具备了运行能力但是还没有得到运行机会的协程,它们随时会被调度到运行态;休眠态的协程还不具备运行能力,它们是在等待某些条件的发生,比如 IO 操作的完成、睡眠时间的结束等。
  4. 子协程的异常退出会将异常传播到主协程,直接会导致主协程也跟着挂掉。

协程一般用 TCP/HTTP/RPC服务、消息推送系统、聊天系统等。使用协程,我们可以很方便的搭建一个支持高并发的TCP或HTTP服务端。

通道

通道的英文是Channels,简称chan。什么时候要用到通道呢?可以先简单的理解为:协程在需要协作通信的时候就需要用通道。

在GO里,不同的并行协程之间交流的方式有两种,一种是通过共享变量,另一种是通过通道。Go 语言鼓励使用通道的形式来交流。

举个简单的例子,我们使用协程实现并发调用远程接口,最终我们需要把每个协程请求回来的数据进行汇总一起返回,这个时候就用到通道了。

创建通道

创建通道(channel)只能使用make函数:

Copy
  1. c := make(chan int)

通道是区分类型的,如这里的int

Go 语言为通道的读写设计了特殊的箭头语法糖 <-,让我们使用通道时非常方便。把箭头写在通道变量的右边就是写通道,把箭头写在通道的左边就是读通道。一次只能读写一个元素。

Copy
  1. c := make(chan bool)
  2. c <- true //写入
  3. <- c //读取

缓冲通道

上面我们介绍了默认的非缓存类型的channel,不过Go也允许指定channel的缓冲大小,很简单,就是channel可以存储多少元素:

Copy
  1. c := make(chan int, value)

当 value = 0 时,通道是无缓冲阻塞读写的,等价于make(chan int);当value > 0 时,通道有缓冲、是非阻塞的,直到写满 value 个元素才阻塞写入。具体说明下:

非缓冲通道
无论是发送操作还是接收操作,一开始执行就会被阻塞,直到配对的操作也开始执行才会继续传递。由此可见,非缓冲通道是在用同步的方式传递数据。也就是说,只有收发双方对接上了,数据才会被传递。数据是直接从发送方复制到接收方的,中间并不会用非缓冲通道做中转。

缓冲通道
缓冲通道可以理解为消息队列,在有容量的时候,发送和接收是不会互相依赖的。用异步的方式传递数据。

下面我们用一个例子来理解一下:

Copy
  1. package main
  2. import "fmt"
  3. func main() {
  4. var c = make(chan int, 0)
  5. var a string
  6. go func() {
  7. a = "hello world"
  8. <-c
  9. }()
  10. c <- 0
  11. fmt.Println(a)
  12. }

这个例子输出的一定是hello world。但是如果你把通道的容量由0改为大于0的数字,输出结果就不一定是hello world了,很可能是空。为什么?

当通道是无缓冲通道时,执行到c <- 0,通道满了,写操作会被阻塞住,直到执行<-c解除阻塞,后面的语句接着执行。

要是改成非阻塞通道,执行到c <- 0,发现还能写入,主协程就不会阻塞了,但这时候输出的是空字符串还是hello world,取决于是子协程和主协程哪个运行的速度快。

通道作为容器,它可以像切片一样,使用 cap() 和 len() 全局函数获得通道的容量和当前内部的元素个数。

模拟消息队列

上一节"协程"的例子里,我们在主协程里加了个time.Sleep(),目的是防止子协程还没有结束主协程就退出了。但是对于实际生活的大多数场景来说,1秒是不够的,并且大部分时候我们都无法预知for循环内代码运行时间的长短。这时候就不能使用time.Sleep() 来完成等待操作了。下面我们用通道来改写:

Copy
  1. package main
  2. import (
  3. "fmt"
  4. )
  5. func main() {
  6. fmt.Println("run in main coroutine.")
  7. count := 10
  8. c := make(chan bool, count)
  9. for i := 0; i < count; i++ {
  10. go func(i int) {
  11. fmt.Printf("run in child coroutine %d.\n", i)
  12. c <- true
  13. }(i)
  14. }
  15. for i := 0; i < count; i++ {
  16. <-c
  17. }
  18. }

单向通道

默认的通道是支持读写的,我们可以定义单向通道:

Copy
  1. //只读
  2. var readOnlyChannel = make(<-chan int)
  3. //只写
  4. var writeOnlyChannel = make(chan<- int)

下面是一个示例,我们模拟消息队列的消费者、生产者:

Copy
  1. package main
  2. import (
  3. "fmt"
  4. "time"
  5. )
  6. func Producer(c chan<- int) {
  7. for i := 0; i < 10; i++ {
  8. c <- i
  9. }
  10. }
  11. func Consumer1(c <-chan int) {
  12. for m := range c {
  13. fmt.Printf("oh, I get luckly num: %v\n", m)
  14. }
  15. }
  16. func Consumer2(c <-chan int) {
  17. for m := range c {
  18. fmt.Printf("oh, I get luckly num too: %v\n", m)
  19. }
  20. }
  21. func main() {
  22. c := make(chan int, 2)
  23. go Consumer1(c)
  24. go Consumer2(c)
  25. Producer(c)
  26. time.Sleep(time.Second)
  27. }

对于生产者,我们希望通道是只写属性,而对于消费者则是只读属性,这样避免对通道进行错误的操作。当然,如果你将本例里消费者、生产者的通道单向属性去掉也是可以的,没什么问题:

Copy
  1. func Producer(c chan int) {}
  2. func Consumer1(c chan int) {}
  3. func Consumer2(c chan int) {}

事实上 channel 只读或只写都没有意义,所谓的单向 channel 其实只是方法里声明时用,如果后续代码里,向本来用于读channel里写入了数据,编译器会提示错误。

关闭通道

读取一个已经关闭的通道会立即返回通道类型的零值,而写一个已经关闭的通道会抛异常。如果通道里的元素是整型的,读操作是不能通过返回值来确定通道是否关闭的。

1、如何安全的读通道,确保不是读取的已关闭通道的零值
答案是使用for...range语法。当通道为空时,循环会阻塞;当通道关闭,循环会停止。通过循环停止,我们可以认为通道已经关闭。示例:

Copy
  1. package main
  2. import "fmt"
  3. func main() {
  4. var c = make(chan int, 3)
  5. //子协程写
  6. go func() {
  7. c <- 1
  8. close(c)
  9. }()
  10. //直接读取通道,存在不知道子协程是否已关闭的情况
  11. //fmt.Println(<-c)
  12. //fmt.Println(<-c)
  13. //主协程读取:使用for...range安全的读取
  14. for value := range c {
  15. fmt.Println(value)
  16. }
  17. }

输出:

Copy
  1. 1

2、如何安全的写通道,确保不会写入已关闭的通道?
Go 语言并不存在一个内置函数可以判断出通道是否已经被关闭。确保通道写安全的最好方式是由负责写通道的协程自己来关闭通道,读通道的协程不要去关闭通道。

但是这个方法只能解决单写多读的场景。如果遇到多写单读的情况就有问题了:无法知道其它写协程什么时候写完,那么也就不能确定什么时候关闭通道。这个时候就得额外使用一个通道专门做这个事情。

我们可以使用内置的 sync.WaitGroup,它使用计数来等待指定事件完成:

Copy
  1. package main
  2. import (
  3. "fmt"
  4. "sync"
  5. "time"
  6. )
  7. func main() {
  8. var ch = make(chan int, 8)
  9. //写协程
  10. var wg = new(sync.WaitGroup)
  11. for i := 1; i <= 4; i++ {
  12. wg.Add(1)
  13. go func(num int, ch chan int, wg *sync.WaitGroup) {
  14. defer wg.Done()
  15. ch <- num
  16. ch <- num * 10
  17. }(i, ch, wg)
  18. }
  19. //读
  20. go func(ch chan int) {
  21. for num := range ch {
  22. fmt.Println(num)
  23. }
  24. }(ch)
  25. //Wait阻塞等待所有的写通道协程结束,待计数值变成零,Wait才会返回
  26. wg.Wait()
  27. //安全的关闭通道
  28. close(ch)
  29. //防止读取通道的协程还没有完毕
  30. time.Sleep(time.Second)
  31. fmt.Println("finish")
  32. }

输出:

Copy

  1. 3
  2. 30
  3. 2
  4. 20
  5. 1
  6. 10
  7. 4
  8. 40
  9. finish

多路通道

有时候还会遇到多个生产者,只要有一个生产者就绪,消费者就可以进行消费的情况。这个时候可以使用go语言提供的select语句,它可以同时管理多个通道读写,如果所有通道都不能读写,它就整体阻塞,只要有一个通道可以读写,它就会继续。示例:

Copy
  1. package main
  2. import (
  3. "fmt"
  4. "time"
  5. )
  6. func main() {
  7. var ch1 = make(chan int)
  8. var ch2 = make(chan int)
  9. fmt.Println(time.Now().Format("15:04:05"))
  10. go func(ch chan int) {
  11. time.Sleep(time.Second)
  12. ch <- 1
  13. }(ch1)
  14. go func(ch chan int) {
  15. time.Sleep(time.Second * 2)
  16. ch <- 2
  17. }(ch2)
  18. for {
  19. select {
  20. case v := <-ch1:
  21. fmt.Println(time.Now().Format("15:04:05") + ":来自ch1:", v)
  22. case v := <-ch2:
  23. fmt.Println(time.Now().Format("15:04:05") + ":来自ch2:", v)
  24. //default:
  25. //fmt.Println("channel is empty !")
  26. }
  27. }
  28. }

输出:

Copy
  1. 13:39:56
  2. 13:39:57:来自ch1: 1
  3. 13:39:58:来自ch2: 2
  4. fatal error: all goroutines are asleep - deadlock!

默认select处于阻塞状态,1s后,子协程1完成写入,主协程读出了数据;接着子协程2完成写入,主协程读出了数据;接着主协程挂掉了,原因是主协程发现在等一个永远不会来的数据,这显然是没有结果的,干脆就直接退出了。

如果把注释的部分打开,那么程序在打印出来自ch1、ch2的数据后,就会一直执行default里面的程序。这个时候程序不会退出。原因是当 select 语句所有通道都不可读写时,如果定义了 default 分支,那就会执行 default 分支逻辑。

注:select{}代码块是一个没有任何caseselect,它会一直阻塞。

Chan的应用场景

golang中chan的应用场景总结
https://github.com/nange/blog/issues/9

Go语言之Channels实际应用
https://www.s0nnet.com/archives/go-channels-practice

  • 消息队列
  • 并发请求
  • 模拟锁的功能
  • 模拟sync.WaitGroup
  • 并行计算

通道原理部分可以根据文末给出的参考链接《快学 Go 语言》第 12 课 —— 通道去查看。

并发锁

互斥所

go语言里的map是线程不安全的:

Copy
  1. package main
  2. import "fmt"
  3. func write(d map[string]string) {
  4. d["name"] = "yujc"
  5. }
  6. func read(d map[string]string) {
  7. fmt.Println(d["name"])
  8. }
  9. func main() {
  10. d := map[string]string{}
  11. go read(d)
  12. write(d)
  13. }

Go 语言内置了数据结构竞态检查工具来帮我们检查程序中是否存在线程不安全的代码,只要在运行的时候加上-race参数即可:

Copy
  1. $ go run -race main.go
  2. ==================
  3. WARNING: DATA RACE
  4. Read at 0x00c0000a8180 by goroutine 6:
  5. ...
  6. yujc
  7. Found 2 data race(s)
  8. exit status 66

可以看出,上面的代码存在安全隐患。

我们可以使用sync.Mutex来保护map,原理是在每次读写操作之前使用互斥锁进行保护,防止其他线程同时操作:

Copy
  1. package main
  2. import (
  3. "fmt"
  4. "sync"
  5. )
  6. type SafeDict struct {
  7. data map[string]string
  8. mux *sync.Mutex
  9. }
  10. func NewSafeDict(data map[string]string) *SafeDict {
  11. return &SafeDict{
  12. data: data,
  13. mux: &sync.Mutex{},
  14. }
  15. }
  16. func (d *SafeDict) Get(key string) string {
  17. d.mux.Lock()
  18. defer d.mux.Unlock()
  19. return d.data[key]
  20. }
  21. func (d *SafeDict) Set(key string, value string) {
  22. d.mux.Lock()
  23. defer d.mux.Unlock()
  24. d.data[key] = value
  25. }
  26. func main(){
  27. dict := NewSafeDict(map[string]string{})
  28. go func(dict *SafeDict) {
  29. fmt.Println(dict.Get("name"))
  30. }(dict)
  31. dict.Set("name", "yujc")
  32. }

运行检测:

Copy
  1. $ go run -race main.go
  2. yujc

上面的代码如果不使用-race运行,不一定会有结果,取决于主协程、子协程哪个先运行。

注意:sync.Mutex 是一个结构体对象,这个对象在使用的过程中要避免被浅拷贝,否则起不到保护作用。应尽量使用它的指针类型。

上面的代码里我们多处使用了d.mux.Lock(),能否简化成d.Lock()呢?答案是可以的。我们知道,结构体可以自动继承匿名内部结构体的所有方法:

Copy
  1. type SafeDict struct {
  2. data map[string]string
  3. *sync.Mutex
  4. }
  5. func NewSafeDict(data map[string]string) *SafeDict {
  6. return &SafeDict{data, &sync.Mutex{}}
  7. }
  8. func (d *SafeDict) Get(key string) string {
  9. d.Lock()
  10. defer d.Unlock()
  11. return d.data[key]
  12. }

这样就完成了简化。

读写锁

对于读多写少的场景,可以使用读写锁代替互斥锁,可以提高性能。

读写锁提供了下面4个方法:

  • Lock() 写加锁
  • Unlock() 写释放锁
  • RLock() 读加锁
  • RUnlock() 读释放锁

写锁排它锁,加写锁时会阻塞其它协程再加读锁写锁读锁共享锁,加读锁还可以允许其它协程再加读锁,但是会阻塞加写锁读写锁在写并发高的情况下性能退化为普通的互斥锁

我们把上节中的互斥锁换成读写锁:

Copy
  1. package main
  2. import (
  3. "fmt"
  4. "sync"
  5. )
  6. type SafeDict struct {
  7. data map[string]string
  8. *sync.RWMutex
  9. }
  10. func NewSafeDict(data map[string]string) *SafeDict {
  11. return &SafeDict{data, &sync.RWMutex{}}
  12. }
  13. func (d *SafeDict) Get(key string) string {
  14. d.RLock()
  15. defer d.RUnlock()
  16. return d.data[key]
  17. }
  18. func (d *SafeDict) Set(key string, value string) {
  19. d.Lock()
  20. defer d.Unlock()
  21. d.data[key] = value
  22. }
  23. func main(){
  24. dict := NewSafeDict(map[string]string{})
  25. go func(dict *SafeDict) {
  26. fmt.Println(dict.Get("name"))
  27. }(dict)
  28. dict.Set("name", "yujc")
  29. }

改完后,使用竞态检测工具检测还是能通过的。

参考

1、make(chan int) 和 make(chan int, 1) 的区别
https://www.jianshu.com/p/f12e1766c19f
2、channel
https://www.jianshu.com/p/4d97dc032730
3、《快学 Go 语言》第 12 课 —— 通道
https://mp.weixin.qq.com/s?__biz=MzI0MzQyMTYzOQ&mid=2247484601&idx=1&sn=97c0de2acc3127c9e913b6338fa65737
4、《快学 Go 语言》第 13 课 —— 并发与安全
https://mp.weixin.qq.com/s?__biz=MzI0MzQyMTYzOQ&mid=2247484683&idx=1&sn=966cb818f034ffd4538eae7a61cd0c58

(本文完)

 

转自 https://www.cnblogs.com/52fhy/p/11369028.html

理解Go协程与并发(转)的更多相关文章

  1. 理解Go协程与并发

    协程 Go语言里创建一个协程很简单,使用go关键字就可以让一个普通方法协程化: package main import ( "fmt" "time" ) fun ...

  2. 11.python3标准库--使用进程、线程和协程提供并发性

    ''' python提供了一些复杂的工具用于管理使用进程和线程的并发操作. 通过应用这些计数,使用这些模块并发地运行作业的各个部分,即便是一些相当简单的程序也可以更快的运行 subprocess提供了 ...

  3. 数据库 tcp协程实现并发 回调函数

    数据库 tcp协程实现并发 回顾 一.回顾 进程池,线程池,回调函数 # from gevent import monkey;monkey.patch_all() #补丁 from gevent im ...

  4. asyncio协程与并发

    并发编程 Python的并发实现有三种方法. 多线程 多进程 协程(生成器) 基本概念 串行:同时只能执行单个任务 并行:同时执行多个任务 在Python中,虽然严格说来多线程与协程都是串行的,但其效 ...

  5. 理解Python协程:从yield/send到yield from再到async/await

    Python中的协程大概经历了如下三个阶段:1. 最初的生成器变形yield/send2. 引入@asyncio.coroutine和yield from3. 在最近的Python3.5版本中引入as ...

  6. Day 42 协程. IO 并发

    一.什么是协程? 是单线程下的并发,又称微线程,纤程.英文名Coroutine.一句话说明什么是线程:协程是一种用户态的轻量级线程,即协程是由用户程序自己控制调度的. 协程相比于线程切换效率更快了. ...

  7. 深入理解python协程

    目录 概述 生成器变形 yield/send yield send yield from asyncio.coroutine和yield from async和await 概述 由于 cpu和 磁盘读 ...

  8. PHP协程:并发 shell_exec

    在PHP程序中经常需要用shell_exec执行一些命令,而普通的shell_exec是阻塞的,如果命令执行时间过长,那可能会导致进程完全卡住.在Swoole4协程环境下可以用Co::exec并发地执 ...

  9. python教程:使用 async 和 await 协程进行并发编程

    python 一直在进行并发编程的优化, 比较熟知的是使用 thread 模块多线程和 multiprocessing 多进程,后来慢慢引入基于 yield 关键字的协程. 而近几个版本,python ...

随机推荐

  1. Unity接入多个SDK的通用接口开发与资源管理(三)

    接着上篇,介绍SDK资源的导入.首先介绍一下Android Studio工程. AS工程可以由多个Module组成,我们可以把某个Module作为我们打包的Module,其他的Module当做资源导入 ...

  2. HTML -- 表单元素2

    (1)<select>元素(下拉列表) <html> <body> <!-- 表单处理程序在action属性中指定 --> <form actio ...

  3. oracle之复杂查询(下):子查询

    复杂查询(下):子查询 8. 1 非关联子查询:返回的值可以被外部查询使用.子查询可以独立执行的(且仅执行一次). 8.1.1 单行单列子查询,子查询仅返回一个值,也称为标量子查询,采用单行比较运算符 ...

  4. UI中列表

    1.ul.ol.dl

  5. [LeetCode]面试题62. 圆圈中最后剩下的数字(数学)

    题目 0,1,,n-1这n个数字排成一个圆圈,从数字0开始,每次从这个圆圈里删除第m个数字.求出这个圆圈里剩下的最后一个数字. 例如,0.1.2.3.4这5个数字组成一个圆圈,从数字0开始每次删除第3 ...

  6. 装个Mysql怎么就这么难?

    2020/5/20 程序员是没有女朋友的~~ 声明:这篇文章是我第一次安装MySQL,主要记录了安装的艰辛过程,内容很凌乱,不适合作为参考. CentOS 6.10安装MySQL一直出问题,换了各种不 ...

  7. Linux 系统中环境变量/etc/profile、/etc/bashrc、~/.bashrc的区别

      /etc/profile./etc/bashrc.~/.bashrc的区别   1> etc目录下存放系统管理和配置文件 (系统配置) etc/profile:  profile为所有的用户 ...

  8. 学习 | iscroll之上拉加载下拉刷新

    引入文件顺序 1.zepto 2.iscroll.js 3.scroll-probe.js 链接 完整代码:https://github.com/dirkhe1051931999/writeBlog/ ...

  9. mariadb 2

    mariadb第二章-增删改   MariaDB 数据类型 MariaDB数据类型可以分为数字,日期和时间以及字符串值. 使用数据类型的原则:够用就行, 尽量使用范围小的,而不用大的 常用的数据类型 ...

  10. 基于MAXIMO的发电行业EAM解决方案

    1. 行业背景 随着我国以“厂网分开,竞价上网”为特点的电力市场的起步和发展,发电厂.发电集团成为独立企业参与市场竞争,原有的“生产型”管理模式已经不再适应市场的需求.发电企业在重视安全质量.保证电力 ...