go语言线程与通道

205 阅读6分钟

go语言线程与通道

线程goroutine

Go语言的并发通过goroutine实现。goroutine类似于线程,属于用户态的线程,我们可以根据需要创建成千上万个goroutine并发工作。goroutine是由Go语言的运行时(runtime)调度完成,而线程是由操作系统调度完成。

Go语言还提供channel在多个goroutine间进行通信。goroutine和channel是 Go 语言秉承的 CSP(Communicating Sequential Process)并发模式的重要实现基础。

Go语言中的goroutine就是这样一种机制,goroutine的概念类似于线程,但 goroutine是由Go的运行时(runtime)调度和管理的。Go程序会智能地将 goroutine 中的任务合理地分配给每个CPU。Go语言之所以被称为现代化的编程语言,就是因为它在语言层面已经内置了调度和上下文切换的机制。

在Go语言编程中你不需要去自己写进程、线程、协程,你的技能包里只有一个技能–goroutine,当你需要让某个任务并发执行的时候,你只需要把这个任务包装成一个函数,开启一个goroutine去执行这个函数就可以了,就是这么简单粗暴。

goroutine

Go语言中使用goroutine非常简单,只需要在调用函数的时候在前面加上go关键字,就可以为一个函数创建一个goroutine。

一个goroutine必定对应一个函数,可以创建多个goroutine去执行相同的函数。

一句话go中使用和创建线程非常简单

启动线程

启动goroutine的方式非常简单,只需要在调用的函数(普通函数和匿名函数)前面加上一个go关键字。

package main

import (
	"fmt"
	"sync"
)

//计数器
var wg sync.WaitGroup

//go
//协程  用户态的线程
func main() {
	//结束时才执行
	defer fmt.Println(" 结束 ")
	wg.Add(1) //计数器 + 1
	//创建启动一个goroutine
	go hello()

	fmt.Println("  func .... ")

	//time.Sleep(time.Second )
	//等 执行函数goroutine执行完 阻塞等所有执行完
	wg.Wait()

}

func hello() {

	fmt.Println("hellow goroutin....")

	//表示执行完  计数器-1
	wg.Done()
}

可以看到就是这么简单
在这里插入图片描述

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

Go运行时的调度器使用GOMAXPROCS参数来确定需要使用多少个OS线程来同时执行Go代码。默认值是机器上的CPU核心数。例如在一个8核心的机器上,调度器会把Go代码同时调度到8个OS线程上(GOMAXPROCS是m:n调度中的n)。

Go语言中可以通过runtime.GOMAXPROCS()函数设置当前程序并发时占用的CPU逻辑核心数。

Go1.5版本之前,默认使用的是单核心执行。Go1.5版本之后,默认使用全部的CPU逻辑核心数。

package main

import (
	"fmt"
	"sync"
)


var wg sync.WaitGroup
//go语言 1.5之前默认跑一个os线程上,之后全部跑满
//m个goroutine可以跑在n哥os线程上
// m:n
func main() {
	//设置跑的cpu核心数 大小  逻辑核
	//runtime.GOMAXPROCS(1)  // 跑在一个核上 要么是A B 一个先执行完 然后才能执行
	//runtime.GOMAXPROCS(2)  // 可以并行  默认跑满
	wg.Add(2)
	go testA()
	go testB()

	wg.Wait()

}

func testA()  {
	defer wg.Done()
	for i:=0;i<10;i++ {
		fmt.Printf("A-> %d \n " ,i )
	}
}


func testB()  {
	defer wg.Done()
	for i:=0;i<10;i++ {
		fmt.Printf("%d \n " ,i )
	}
}

多试几次,不一定每次就是成功的,cpu执行时间太短,我们打印的数据太少
可以看到,2个线程执行,如果是一个执行数字肯定是连着 ,也就是执行完之后另一个才执行
在这里插入图片描述

通道

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

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

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

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

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

定义通道

channel是一种类型,一种引用类型。声明通道类型的预发:

var 变量 chan 元素类型

如基本语法以及使用:


//通道基础语法
func chanelFun(){

var c1 chan int // 定义Int 类型的通道
var c2 chan string // 定义 string 类型的通道
//默认值 nil
fmt.Println(c1)
fmt.Println(c2)
//初始化
//c2 = make(chan string)  //未初始化 容量 会出现死锁
c2 = make(chan string,3)  //超过容量也会死锁
//往c2通道里放数据
c2 <- "hello1 "
c2 <- "hello2 "
c2 <- "hello3 "

//从c2中拿取 直接丢弃
//<- c2
//从通道c2 中拿数据
str := <- c2
fmt.Println("拿到的数据: ",str)
str = <- c2
fmt.Println("拿到的数据: ",str)

//关闭  关闭后还能继续从通道拿数据
close(c2)

str = <- c2
fmt.Println("关闭通道后 拿到的数据: ",str)

//关闭后还能继续从通道拿数据
//直到没有数据 拿到的值是对应数据类型的默认值
str = <- c2
fmt.Println("没有数据了: ",str)
}

关闭通道直接使用 close(xx)即可

关闭后的通道有以下特点:

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

channel操作

通道可以写入数据 也可以往外读数据,基本操作如下

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

在使用无缓冲通道时候,如果没有指定容量通道此通道被称为无缓冲通道

	var c2 chan string // 定义 string 类型的通道
	//初始化 未初始化容量 表示无 缓冲通道
	c2 = make(chan string)  //未初始化 容量 会出现死锁
	c2<- "abc"
	str:=<-c2

在这里插入图片描述
创建的是无缓冲的通道,无缓冲的通道只有在有人接收值的时候才能发送值,无缓冲通道上的发送操作会阻塞,对此我们需要使用一个goroutine进行接收


//无缓冲通道
func NoCachFun(){
	var c2 chan string // 定义 string 类型的通道
	//初始化 未初始化容量 表示无 缓冲通道
	c2 = make(chan string)  //未初始化 容量 会出现死锁
	//启动一个goroutine 此时通道没有数据 会一直阻塞  、 使用 场景 异步场景 比如记录日志 发送邮件
	go receviceMsg(c2)

	//往通道里放数据
	c2 <- "abc"
	fmt.Println(" 运行结束 ")

}

//从通道接收数据
func receviceMsg(c2 chan string) {
	str :=  <-c2  // 没有值会 阻塞
	fmt.Println(" 从通道获取的值 " , str)
}

可以看到辞此时运行不会报错,也成功接收到消息在这里插入图片描述
无缓冲通道上的发送操作会阻塞,直到另一个goroutine在该通道上执行接收操作,这时值才能发送成功,两个goroutine将继续执行。相反,如果接收操作先执行,接收方的goroutine将阻塞,直到另一个goroutine在该通道上发送一个值。

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

还有一种解决办法就是使用有缓冲通道,也就是创建通道时候指定容量。


//有缓冲通道
func HasCashChanel(){


	var c2 chan string // 定义 string 类型的通道
	//初始化 未初始化容量 表示无 缓冲通道
	//指定容量 表示通道可以存放多少数据
	c2 = make(chan string,1)
	//启动一个goroutine 此时通道没有数据 会一直阻塞
	go receviceMsg(c2)  //todo goroutine 函数执行完 一个 goroutine表示执行完成

	//往通道里放数据
	c2 <- "abc1"
	fmt.Println(" 运行结束 ")

	time.Sleep(time.Second)

}

//从通道接收数据
func receviceMsg(c2 chan string) {
	str :=  <-c2  // 没有值会 阻塞
	fmt.Println(" 从通道获取的值 " , str)
}

运行结果如下
在这里插入图片描述

关闭通道

使用close()方法关闭通道时候,如果没有值,继续拿值拿到的会是对应对应的默认值,那如何判断通道是否被关闭了,通道中没有数据呢?

如下 通道是10个数据,我们循环获取 15次


	var c chan int
	c = make(chan int,10)


	for i:=0;i<10;i++{
		c <- i //给通道里放值
	}
	close(c) // 不能

	//从通道里取值
	for i:=0;i<15;i++ {

		num:= <-c
		
		fmt.Println(num)

	}

结果如下
在这里插入图片描述

在go语言中 可以通过内置函数返回的判断来进行

var c chan int
	c = make(chan int,10)

	//go send(c)
	for i:=0;i<10;i++{
		c <- i //给通道里放值
	}
	close(c) // 不能

	//从通道里取值
	for  {
		//可以通过go语言内置函数 判断通道是否关闭
		num, ok:= <-c
		if !ok {
			break
		}
		fmt.Println(num)

	}

可以看到打印完响应的数据
在这里插入图片描述
还有一种方法,go语言中 for range来打印,range中内置了判断

for n:= range c {
		fmt.Println(n)
	}

使用for range遍历通道,当通道被关闭的时候就会退出for range

select 多路复用

在某些场景下我们需要同时从多个通道接收数据。通道在接收数据时,如果没有数据可以接收将会发生阻塞。此时就可以用到select 多路复用

由于从多个chanel中获取数据,其中某个chanel可能会存在阻塞情况,此时我们就需要从其他chanel中取数据,这样可以同时响应多个通道的操作。
具体语法:

select{
case <-ch1:

case data := <-ch2:

case ch3<-data:

default:
默认操作
}

select的使用类似于switch语句,它有一系列case分支和一个默认的分支。每个case会对应一个通道的通信(接收或发送)过程。select会一直等待,直到某个case的通信操作完成时,就会执行case分支对应的语句。

如下示例所示:


//多路复用
func moreChanelTest()  {

	var c1 chan int
	c1 = make(chan int)

	var c2 chan int
	c2 = make(chan int)

	for {

		//给通道放数据
		go test1(c1)
		go test2(c2)

		select {
		case str1,ok1:= <-c1:
			if ok1 {
				fmt.Println("c1 : " ,str1)
			}
		case str2,ok2:= <-c2:
			if ok2 {
			fmt.Println("c2 : " ,str2)
			}
		default:
			fmt.Println("暂时没有取到值")
		}
		time.Sleep(time.Millisecond * 200)

	}

}

func test1(c1 chan int) {
	for i:=0;i<100;i++ {
		time.Sleep(time.Second *  2)
		c1 <- 1
	}
}


func test2(c2 chan int) {
	for i:=0;i<100;i++ {
		time.Sleep(time.Second *  3)
		c2 <- 2
	}
}

两个goroutine中设置值,从2个goroutine中获取数据,如果一个阻塞就从另一个中获取。

在这里插入图片描述
select多路复用
可处理一个或多个channel的发送/接收操作。
如果多个case同时满足,select会随机选择一个。
对于没有case的select{}会一直等待,可用于阻塞main函数。