Golang 之 并发
并发
主要包括goroutine定义,执行与调度,channel操作,goroutine与线程关系
首先明确并发与并行的关系:
-
并发:同一时间段内执行多个任务(我在跑步,停下来擦会儿汗,再接着跑)
-
并行:同一时刻执行多个任务(我一边跑步一边擦汗)
然后是进程/线程/协程之间的区别:
进程:运行的程序,比如说QQ,微信
线程:多线程编程,java,python.....> >
协程(goroutine) ——>go
Go语言的并发通过 goroutine 实现。 goroutine类似于线程,属于用户态的线程,即我们自己写的线程,我们可以根据需要创建成千上万个goroutine并发工作。
goroutine是由Go语言的运行时(runtime)调度完成,而线程是由操作系统调度完成。
Go语言还提供channel在多个goroutine间进行通信。goroutine和channel是 Go 语言秉承的 CSP(Communicating Sequential Process)并发模式的重要实现基础。
Go程序会智能地将 goroutine 中的任务合理地分配给每个CPU。Go语言之所以被称为现代化的编程语言,就是因为它在语言层面已经内置了调度和上下文切换的机制。
在Go语言编程中我们不需要自己写进程、线程、协程,only goroutine 当我们需要让某个任务并发执行的时候,只需要把这个任务包装成一个函数,开启一个goroutine去执行这个函数就OK了。
#在调用的函数(普通函数和匿名函数)前面加上一个go关键字便可以调用goroutine了
func hello() {
fmt.Println("Hello Goroutine!")
}
#运行后发现只打印了main goroutine done
func main() {
go hello()
fmt.Println("main goroutine done!")
}
注意:当main()函数返回的时候该goroutine就结束了,所有在main()函数中启动的goroutine会一同结束。main函数所在的goroutine就像是权利的游戏中的夜王,其他的goroutine都是异鬼,夜王一死它转化的那些异鬼也就全部GG了<无比形象!!!>。
#所以一般让主函数等一等,粗暴....
time.Sleep(time.Second*3)
其他还有许多保持goroutine同步和不相干扰的方法,例如sync.WaitGroup,互斥锁,读写锁等等
# 采用 sync.WaitGroup 保持同步
# 启用多个 goroutine
var wg sync.WaitGroup
func hello(i int) {
// 函数执行完,wg计数器-1
defer wg.Done()
fmt.Println("Hello Goroutine!", i)
}
func main() {
for i := 0; i < 10; i++ {
// 启动一个goroutine,wg计数器+1(这个例子只启动了一个goroutine)
wg.Add(1)
go hello(i)
}
// 等待所有登记的goroutine都结束,即wg计数器减为0,main函数才结束
wg.Wait()
}
通道 channel
单纯地将函数并发执行是没有意义的。函数与函数间需要交换数据才能体现并发执行函数的意义。
虽然可以使用共享内存进行数据交换,但是共享内存在不同的goroutine中容易发生竞态问题。为了保证数据交换的正确性,必须使用互斥量对内存进行加锁,这种做法势必造成性能问题。(互斥锁and读写锁)
Go语言的并发模型是CSP(Communicating Sequential Processes),提倡通过通信共享内存而不是通过共享内存而实现通信。
如果说goroutine是Go程序并发的执行体,channel就是它们之间的连接。channel是可以让一个goroutine发送特定值到另一个goroutine的通信机制。
Go 语言中的通道(channel)是一种特殊的类型。通道像一个传送带或者队列,总是遵循先入先出(First In First Out)的规则,保证收发数据的顺序。每一个通道都是一个具体类型的导管,也就是声明channel的时候需要为其指定元素类型。
channel基本使用
// 声明通道
var ch1 chan 数据类型
// 初始化通道
ch := make(chan 数据类型)
//向通道中写入数据(将data发送入通道)
ch <- data
//读取通道(ch)中的数据(data接收通道内的内容)
data := <-ch
//关闭通道,避免阻塞
close(ch)
//函数调用
func fun1(ch chan 数据类型) {...} //双向通道
func fun2(ch chan <- 数据类型) {...} //定向通道:在该函数中,只能向通道ch中写入数据
func fun3(ch <- chan 数据类型) {...} //定向通道:在该函数中,只能读取通道ch中的数据
channel实例
package main
import "fmt"
func main() {
var ch1 chan bool
ch1 = make(chan bool)
//执行goroutine
go func() {
for i := 0; i <= 10; i++ {
fmt.Println("子goroutine中,i:", i)
}
//向通道中写数据
ch1 <- true
}()
//读取通道中数据
data := <-ch1
fmt.Println("main...data-->", data)
fmt.Println("执行完毕")
}
具有缓冲区的channel
无缓冲的通道只有在有人接收值的时候才能发送值,反之同理。
而有缓冲的通道,只要通道的容量大于零,那么该通道就是有缓冲的通道,通道的容量表示通道中能存放元素的数量。就像你小区的快递柜只有那么个多格子,格子满了就装不下了,就阻塞了,等到别人取走一个快递员就能往里面放一个。
package main
import (
"fmt"
"strconv" //转换数据类型
)
func sendData(ch chan string) {
for i := 0; i < 10; i++ {
//数据写入通道
ch <- "数据" + strconv.Itoa(i) //int转字符串
fmt.Printf("子goroutine中写出第 %d 个数据 \n", ch)
}
//for执行完毕,关闭通道,避免阻塞
close(ch)
}
func main() {
//定义缓冲通道,容量为4
ch1 := make(chan string, 4)
//执行子goroutine
go sendData(ch1)
for {
//读取数据,一旦读取完(通道close),ok=false,执行if内容
//可以用range替换
v, ok := <-ch1
if !ok {
fmt.Println("读完了。。。", ok)
break
}
fmt.Println("\t读取的数据是:", v)
}
fmt.Println("主通道执行完毕")
}
goroutine与线程
可增长的栈
OS线程(操作系统线程)一般都有固定的栈内存(通常为2MB),一个goroutine的栈在其生命周期开始时只有很小的栈(典型情况下2KB),goroutine的栈不是固定的,他可以按需增大和缩小,goroutine的栈大小限制可以达到1GB,虽然极少会用到这个大。所以在Go语言中一次创建十万左右的goroutine也是可以的。
goroutine调度
GPM是Go语言运行时(runtime)层面的实现,是go语言自己实现的一套调度系统。区别于操作系统调度OS线程。
G很好理解,就是个goroutine的,里面除了存放本goroutine信息外 还有与所在P的绑定等信息。P管理着一组goroutine队列,P里面会存储当前goroutine运行的上下文环境(函数指针,堆栈地址及地址边界),P会对自己管理的goroutine队列做一些调度(比如把占用CPU时间较长的goroutine暂停、运行后续的goroutine等等)当自己的队列消费完了就去全局队列里取,如果全局队列里也消费完了会去其他P的队列里抢任务。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,但不会太多,切换太频繁的话得不偿失。
单从线程调度讲,Go语言相比起其他语言的优势在于OS线程是由OS内核来调度的,goroutine则是由Go运行时(runtime)自己的调度器调度的,这个调度器使用一个称为m:n调度的技术(复用/调度m个goroutine到n个OS线程)。 其一大特点是goroutine的调度是在用户态下完成的, 不涉及内核态与用户态之间的频繁切换,包括内存的分配与释放,都是在用户态维护着一块大的内存池, 不直接调用系统的malloc函数(除非内存池需要改变),成本比调度OS线程低很多。 另一方面充分利用了多核的硬件资源,近似的把若干goroutine均分在物理线程上, 再加上本身goroutine的超轻量,以上种种保证了go调度方面的性能。
GOMAXPROCS
comaxprocs
Go运行时的调度器使用GOMAXPROCS参数来确定需要使用多少个OS线程来同时执行Go代码。默认值是机器上的CPU核心数。例如在一个8核心的机器上,调度器会把Go代码同时调度到8个OS线程上(GOMAXPROCS是m:n调度中的n)。
Go语言中可以通过runtime.GOMAXPROCS()函数设置当前程序并发时占用的CPU逻辑核心数。
Go1.5版本之前,默认使用的是单核心执行。Go1.5版本之后,默认使用全部的CPU逻辑核心数。
我们可以通过将任务分配到不同的CPU逻辑核心上实现并行的效果,这里举个例子:
func a() {
for i := 1; i < 10; i++ {
fmt.Println("A:", i)
}
}
func b() {
for i := 1; i < 10; i++ {
fmt.Println("B:", i)
}
}
func main() {
//设置当前程序并发时占用的CPU逻辑核心数为1
runtime.GOMAXPROCS(1)
go a()
go b()
time.Sleep(time.Second)
}
两个任务只有一个逻辑核心,此时是做完一个任务再做另一个任务。 将逻辑核心数设为2,此时两个任务并行执行,代码如下。
func a() {
for i := 1; i < 10; i++ {
fmt.Println("A:", i)
}
}
func b() {
for i := 1; i < 10; i++ {
fmt.Println("B:", i)
}
}
func main() {
//设置当前程序并发时占用的CPU逻辑核心数为2
runtime.GOMAXPROCS(2)
go a()
go b()
time.Sleep(time.Second)
}
Go语言中的操作系统线程和goroutine的关系:
-
一个操作系统线程对应用户态多个goroutine。
-
go程序可以同时使用多个操作系统线程。
-
goroutine和OS线程是多对多的关系,即m:n。
案例一
需求:
要求统计 1-200000 的数字中,哪些是素数?这个问题在本章开篇就提出了,现在我们有 goroutine
和 channel 的知识后,就可以完成了 [测试数据: 80000]
分析:
传统的方法,就是使用一个循环,循环的判断各个数是不是素数【ok】。
使用并发/并行的方式,将统计素数的任务分配给多个(4 个)g
思路图:

代码:
package main
import "fmt"
// 向 intChan 放入 8000 个数
func putNum(intChan chan int) {
for i := 1; i <= 8000; i++ {
intChan <- i
}
// 关闭intChan
close(intChan)
}
// 从intChan取出数据,并判断是否为素数
// 如果是,放入primeChan
func primeNum(intChan chan int, primeChan chan int, exitChan chan bool) {
var flag bool
// 使用for循环
for {
num, ok := <-intChan
if !ok { // intChan取不到
break
}
flag = true // 假定flag是素数
// 判断num是不是素数
for i := 2; i < num; i++ {
if num%i == 0 { // 说明该num不是素数
flag = false
break
}
}
if flag {
// 将这个数放入primeChan
primeChan <- num
}
}
fmt.Println("有一个协程因为取不到数据,退出")
// 这里我们还不能关闭primeChan
// 向exitChan 写入true
exitChan <- true
}
func main() {
intChan := make(chan int, 1000)
primeChan := make(chan int, 2000) // 放入结果
// 标识退出的管道
exitChan := make(chan bool, 4) // 4个
// 开启一个写完成,向intChan放入8000个数
go putNum(intChan)
// 开启4个协程,从intChan取出数据,并判断是否为素数
// 如果是,放入primeChan
for i := 0; i < 4; i++ {
go primeNum(intChan, primeChan, exitChan)
}
// 这里我们主线程进行处理
//
func() {
for i:=0;i<4;i++ {
<- exitChan
}
// 当我们从exitChan 取出了4个结果,就可以放心的关闭primeNum
close(primeChan)
}()
// 遍历我们的primeChan,把结果取出
for {
res, ok := <- primeChan
if !ok {
break
}
// 将结果输出
fmt.Printf("素数=%d\n", res)
}
fmt.Println("main 线程退出")
}
案例二
需求
- 启动一个协程,将1-200000的数放入到一个channel中,比如numChan
- 启动8个协程,从numChan取出数(比如n),计算1+...+n的值,并存档到resChan中
- 最后8个协程协同完成工作后,再遍历resChan,显示结果【如res[1]=1......res[10]=55】
- 注意:考虑resChan chan int 是否合适
分析:并发形式,8个协程共同计算1+...+n的值
代码:
package main
import "fmt"
// 写
func writeNum(numChan chan int) {
// 放入1-2000
for i:=1;i<=200000;i++ {
numChan <- i
}
// 关闭
close(numChan)
}
// 计算1+...n
func addNum(n int) int {
sum := 0
for i:=1;i<=n;i++ {
sum += i
}
return sum
}
// 读,从numChan取出数
func readNum(numChan chan int, resChan chan int, exitChan chan bool) {
for {
num, ok := <- numChan
// 读完退出
if !ok {
break
}
res := addNum(num)
// 把数加起来,并放入结果通道
resChan <- res
}
fmt.Println("有一个协程因为取不到数据,退出")
// 这里不能关闭resChan
// 向exitChan 写入true, 8次之后才退出
exitChan <- true
}
func main() {
numChan := make(chan int, 200000)
resChan := make(chan int, 200000) // 放入结果
// 标识退出的管道
exitChan := make(chan bool, 8)
// 写入
go writeNum(numChan)
// 读取
for i := 0; i<8; i++ {
go readNum(numChan, resChan, exitChan)
}
// 主线程处理,防止主线程先运行完毕
func() {
for i:=0;i<8;i++ {
<- exitChan
}
// 从exitChan取出八个结果之后,代表此时上面8个读取协程已经运行完毕
close(resChan)
}()
count := 1
// 遍历resChan,取出结果
for {
res, ok := <- resChan
if !ok {
break
}
// 输出
fmt.Printf("1 + ... + %d = %d\n", count, res)
count++
}
fmt.Println("主线程结束")
}
部分内容参考李文周博客,部分参考韩顺平课程

浙公网安备 33010602011771号