concurrency
什么是并发
上一句代码执行完,才会执行下一句代码,这样的代码逻辑简单,也符合我们的阅读习惯
但是这样是不够的,因为计算机很强大,如果你让它干完一件事再去干另外一件事,那么这就太浪费了
比如一款音乐软件,你在听音乐的同时还能预览今日排行
同一时刻做了2件事,在编程中这就是并发;并发可以让你在同一时刻做多个事情
讲并发其实绕不开线程,在讲线程之前先介绍下什么是进程
并发vs并行
并发:电脑同时听歌,看小说,看电影。cpu根据时间片进行划分,交替执行这3个程序,给人的感觉是同时产生的
并行:多个cpu(多核,类似马拉松多个跑道,互不干涉)同时执行
c语言里实现并发过程使用的是多线程(c++的最小资源单元),进程
go语言里面不是线程,而是go程 =》 goroutine,go程是go语言原生支持的
每一个go程占用的系统资源远远小于线程,一个go程大约需要4k-5k的内存资源,一个程序可以启动大量的go程
- 线程 =》几十个
- go程可以启动成百上千个, ===〉对于实现高并发,性能非常好
- 只需要在目标函数前加上go关键字即可
进程和线程
在操作系统中,进程是非常重要的概念
比如你启动一个软件,就像我们常用的浏览器,操作系统会为这个软件创造一个进程,这个进程是该软件的工作空间,它包含了这个软件运行需要的所有资源,像内存空间、文件句柄还有线程
下面是我mac上进程的截图:
什么是线程?
- 线程是进程的执行空间
- 一个进程可以有多个线程
- 线程被操作系统调度执行,比如一边浏览网页 一边听音乐 下载文件等 这种多线程的操作就是多线程的并发
一个程序的启动,就会有对应的进程被创建,同时进程也会启动一个线程,这个线程就叫做主线程;如果主线程结束了,那么整个程序就退出了
有了主线程,你就可以从主线程启动多个子线程,也就有了多线程的并发
协程(GOROUTINE)
go语言中是没有线程的概念的,只有协程(GOROUTINE)
相比线程来说,协程更加轻量,一个golang程序可以轻轻松松开启成千上万个goroutine
协程是go运行时被调用的,这一点和线程时不一样的;也就是说go语言的并发是由go自己调度的 自己决定同时执行多个goroutine 什么时候执行哪一个
但是对于我们开发者来说,它完全透明,你只要在编码的时候告诉go语言 要启用几个协程;至于如何调度,如何执行,这些我们都不用关心
启动一个协程非常简单 go语言为我们提供了go关键字 相对于其他语言简化了很多
go语言启动协程的语法:
go somefunc()
// or
go func(){}()
示例:
package main
import (
"fmt"
"time"
)
func main() {
go fmt.Println("hello, message from goroutine")
fmt.Println("this is main goroutine")
time.Sleep(time.Second)
}
上面的程序有2个协程:一个是main函数启动的协程,一个是通过go关键字启动的协程
执行:
可以看到"this is main goroutine"被优先打印,说明程序并不是按顺序执行的,而是并发执行的
用go关键字启动的协程并不会阻塞main协程的执行
说明:time.Sleep(time.Second)
是为了保证子协程有充足的时间执行fmt.Println("this is main goroutine")
;否则,主协程执行完fmt.Println("this is main goroutine")
,上面的goroutine还来不及执行,程序就退出了
关于线程和进程可以看看 阮一峰的进程与线程的一个简单解释
通信
如果启动了多个goroutine,那它们之间该如何通信呢?
声明一个channel:
ch := make(chan sometype)
说明:chan是一个关键字,表示的是channel类型;sometype表示的是channel的数据类型为sometype
发送和接收数据:
发送:向chan发送值,把值放在chan中,操作符为 chan <- sometype
接收:获取chan中的值,操作符为: <-chan
改造上面的代码:
package main
import (
"fmt"
)
func main() {
ch:= make(chan string)
go func() {
ch <- "hello, message from goroutine"
}()
fmt.Println("this is main goroutine")
fmt.Println(<-ch) //无缓冲channel有阻塞代码的作用(即同步)
}
可以看到程序仍然可以正常输出,而且不用再设置延迟,ch起到了time.Sleep()
的作用
分析上面的代码:主协程有个ch在接收值,如果ch里无值,那么主协程会一直卡在那里,直到有人(也就是这里的子协程)给它发数据
channel就像2个协程之间架设的管道,一个协程丛里面发送数据,另一个协程丛里面接收数据,有点类似平时的队列
无缓冲channel无论收发数据,都会阻塞:
package main
import (
"fmt"
)
func main() {
ch:= make(chan string)
go func() {
ch <- "hello, message from goroutine"
fmt.Println("do something else")
}()
fmt.Println("this is main goroutine")
fmt.Println(<-ch)
}
可以看到fmt.Println("do something else")
并没有被执行:因为channel发送数据的时候也是阻塞的,当主程序接收到"hello, message from goroutine"
程序就直接退出了,子协程根本没机会输出
我们让主协程多坚挺1s在看看输出:
package main
import (
"fmt"
"time"
)
func main() {
ch:= make(chan string)
go func() {
ch <- "hello, message from goroutine"
fmt.Println("do something else")
}()
fmt.Println("this is main goroutine")
fmt.Println(<-ch)
time.Sleep(time.Second)
}
可以看到"do something else"被成功打印了
无缓冲channel
容量为0,不能存储任何数据
所以无缓冲channel只起到了传输数据的作用
数据并不会在channel中做任何停留
也可以成为同步channel(丛上面的示例可以看出)
有缓冲的channel
有缓冲的channel可以类似为可阻塞的队列,内部元素是先进先出的
可以通过make的第二个参数指定通道容量的大小,进而可以创建一个有缓冲的通道
//语法
cacheCh := make(chan sometype,n)
以下图为例,cacheCh可以最多放5个数据类型为int的数据
有缓冲的channel特点:
- 有缓冲的channel的内部有一个缓冲队列
- 发送操作是向队列的尾部插入元素;如果队列已满,则阻塞等待,知道另一个goroutine执行,接收操作释放队列空间
- 接收操作是丛队列的头部获取元素并把它丛队列中删除;如果队列为空,则阻塞等待,直到另一个goroutine执行,发送操作插入新数据
关闭channel
通道可以被创建,也可以被关闭, 我们可以通过内置的close函数来关闭channel
//语法
close(channel)
通道一旦被关闭,你就不能向里面发送数据了,如果发送就会引起异常;但是可以接受数据,数据为该通道元素类型零值
package main
import (
"fmt"
)
func main() {
numch := make(chan int,10)
for i := 0; i < 10; i++ {
numch <- i
fmt.Println("写入数据", i)
}
fmt.Println("数据写入完毕,准备关闭通道")
close(numch)
//for range是不知道通道是否已经关闭了,所以会一直在那里等待
//在写入端,将通道关闭,for range遍历关闭的通道时,会退出
//for v:=range numch{
// fmt.Println("读取数据:",v)
//}
for{
v, ok := <- numch
if !ok{
break
}
fmt.Println("读取数据:",v)
}
fmt.Println("OVER!")
}
需要只要通道的状态,如果已经关闭了,读不怕,会返回零值;如果再写入,有崩溃的风险.
map: ==> v,ok := m1[0]
channel: ===> v,ok:= <- numChan (如果ok则是通的)
单向channel
有时候我们有一些特殊的业务需求:
限制一个channel值可以接收但是不能发送数据或者限制channel只能发送但不能接收,这种channel称为单向channel
//语法
onlySend := make(chan<-sometype)
onlyReceive := make(<-chan sometype)
单向channel可以防止其他操作影响该channel
select + channel 实现多路复用
eg: 启动3个goroutine进行下载,并把结果发送到3个channel中,哪个先下载好,就会用哪个channel的结果
分析:在这种情况下,如果我们尝试去获取第一个通道的结果,那么程序就会被阻塞,无法获取2,3通道的结果,也就是说你无法判断哪个先下载好,这时候我们就需要使用多路复用的操作
在go语言中,可以通过select语句实现多路复用,语法如下:
多路复用你可以理解为多个通道只要任意一个通道有结果,select都可以监听到,然后就会执行相应的分支,接收数据并处理。
有了select就可以实现下载的例子了:
package main
import (
"fmt"
"math/rand"
"time"
)
func main() {
//声明3个存放结果的channel
firstCh:=make(chan string)
secondCh:=make(chan string)
thirdCh:=make(chan string)
//同时开启3个goroutine进行下载
go func() {
firstCh<-downloadFile("firstCh")
}()
go func() {
secondCh<-downloadFile("secondCh")
}()
go func() {
thirdCh<-downloadFile("thirdCh")
}()
//每个case都有被平等执行的机会
select {
case filePath:=<-firstCh:
fmt.Println(filePath)
case filePath:=<-secondCh:
fmt.Println(filePath)
case filePath:= <-thirdCh:
fmt.Println(filePath)
}
}
func downloadFile(chanName string) string{
//模拟文件下载
rand.Seed(time.Now().Unix())
time.Sleep(time.Millisecond*time.Duration(rand.Intn(1000)))
return chanName+": finished downloading"
}
执行:
for select case
package main
import (
"fmt"
"time"
)
//当程序中有多个channel协同工作,ch1,ch2,某一时刻,ch1或ch2触发了,程序响应处理
//使用select来监听多个通道,当通道被触发时(写入数据,读取数据,关闭通道)
//select语法与switch case很像,但是素偶哦的分支条件必须是通道io
func main() {
ch1 := make(chan int)
ch2 := make(chan int)
//启动一个go程 负责监听两个channel
go func() {
for {
fmt.Println("监听中。。。。")
select {
case data1:=<-ch1:
fmt.Println("从ch1中读取数据:",data1)
case data2:=<-ch2:
fmt.Println("从ch2中读取数据:",data2)
default:
fmt.Println("default分支被触发!")
time.Sleep(time.Second*3/2)
}
}
}()
//启动go1 写ch1
go func() {
for i := 0; i < 5; i++ {
ch1 <- i
time.Sleep(time.Second/2)
}
}()
//启动go2 写ch2
go func() {
for i := 0; i < 5; i++ {
ch2 <- i
time.Sleep(time.Second)
}
}()
for {
time.Sleep(time.Second*4)
fmt.Println("main process blocked")
}
}
如何让多任务正确使用并发
来看看下面的代码,假如我们有10个任务,为了提升效率于是我们选择了并发,请看下面的代码,想一想执行之后会出现什么结果?
package main
import (
"fmt"
"sync"
)
func main() {
var wg sync.WaitGroup
for i := 0; i < 10; i++ { //模拟有10个任务正在执行
wg.Add(1)
go func(n int) {
defer wg.Done()
fmt.Println(i)//模拟正在执行任务
}(i)
}
wg.Wait()
}
run:
为什么会出现这种结果?因为携程都没来的及时开启for循环已经遍历完10次了
for i := 0; i < 10; i++ { //模拟有10个任务正在执行
wg.Add(1)
}
本来程序执行到这里就要退出的,但是遇到了wg.Wait()
于是被block住了,于是就开启了10个goroutine,此时的i已经为10了
如何解决这类问题呢
利用channel双向阻塞的特性,我们可以定义一个channel,让外部去接收/阻塞, 让goroutine去通知外面
来看下面的代码:
package main
import (
"fmt"
"sync"
)
func main() {
var wg sync.WaitGroup
okCh := make(chan bool)
for i := 0; i < 10; i++ {
wg.Add(1)
go func(n int) {
defer wg.Done()
fmt.Println(i) //DO TASK
okCh <- true
}(i)
<-okCh
}
wg.Wait()
}
run
看来我们已经达到了预期,但是又有人提问了,如果DO TASK很耗时,你也能拿到正确的i吗?改造代码,我们再来测试
package main
import (
"fmt"
"sync"
"time"
)
func main() {
var wg sync.WaitGroup
okCh := make(chan bool)
for i := 0; i < 10; i++ {
wg.Add(1)
go func(n int) {
fmt.Println("i:", i) //耗时任务之前打印,这个i一般用于取slice中的数据
okCh <- true
defer wg.Done()
time.Sleep(time.Second * 3)
fmt.Println(i)//耗时任务之后打印 3s后内存那个i早变了
}(i)
<-okCh
}
wg.Wait()
}
run
可以看到我们只要在耗时任务之前拿到这个i对应的数据即可
又有人发问了,这个i一定是正确的吗
我们来研究一下这个for循环
for i := 0; i < 10; i++ {
wg.Add(1)
go func(n int) {
fmt.Println("i:", i) //耗时任务之前打印
okCh <- true
defer wg.Done()
time.Sleep(time.Second * 3)
fmt.Println(i)//耗时任务之后打印
}(i)
<-okCh
}
当i=0
时,goroutine一闪而过直接交给了go语言的调度器,本来是要退出第一次for循环的,但是被<-okCh
block住,于是被迫等goroutine开启,开启后goroutine正确的拿到了i=0
;于是okCh发送true,被外部的<-okCh
拿到,此时for循环执行完毕,i变成了1,依次类推...
所以这个i是正确的(如果你难以理解,可以把channel看做不同的worker)
go语言goroutine之间的通讯是通过channel来完成的,channel具有双向阻塞性
利用channel可以让异步程序达到同步的效果
总结
- 通过go关键字去启动一个协程goroutine
- 如何通过channel实现goroutine间数据的传输
- 提倡通过通信来共享内存,而不是通过共享内存来通信
思考
channel是如何做到并发安全的?