第十二章:并发与通道

Go语言最大的特点就是从语言层面支持并发,利用协程和通道实现并发。

为什么并发?

程序中有很多耗时的工作,如上传文件,下载文件,网络聊天,这时候,一个线程是服务不了多用户的,会产生资源独占导致的等待问题。对资源请求,数据库操作很频繁时,需要使用并发的手段(多线程是手段之一)来解决。

并发意味着程序可以有多个上下文,多个调用栈
调用栈是计算机科学中存储有关正在运行的子程序的消息的栈
经常用于存放子程序的返回地址,使得主程序记录子程序结束后需要返回到的地址。

并发编程有多进程编程,多线程编程,分布式编程。

GO并发优势:

1、GO语言从语言层面支持并发
这意味着什么呢,意味着开发者不用担心底层逻辑,内存管理,这些在语言层面已经实现了,因为并发编程的内存管理非常复杂,这样只需编写好自己的业务逻辑即可。
2、Go语言也提供了十分强大的自动垃圾回收机制,无需担心创建的量如何销毁。
3、不需要额外引用其他第三方库,只需使用"go"关键字就可以使一个函数变为并发函数,实现一个并发程序。

GO并发模型:

Go语言并发基于CSP(Communication Sequential Process)模型,20世纪70年代提出的用于描述两个独立的并发实体通过共享通信管道进行通信的并发模型。

基于CSP模型意味着可以避免显式锁,比如资源竞争中多个金亨同时获取文件资源并进行修改,首先拿到资源的进程会加上锁,等修改完之后再把锁去掉,给下一个进程修改,避免数组出现不一致。(数据同步问题)
Go语言同步不是通过锁的方式,而是通信的方式,通过安全的通道发送和接收数据以实现同步。

goroutine也叫协程,比线程更加轻量,而且go语言内部已经实现了goroutine之间的内存共享。

一旦使用go关键字,就不能使用函数返回值来与主进程进行数据交换,而只能使用channel。

线程与协程区别:

1、线程有固定的栈,基本上2MB,固定分配,用于在函数切换时保存局部变量。goroutine这种轻量级的协程可以有很多,若有固定的栈会导致资源浪费,go中采用动态扩张收缩的策略,初始化为2KB,最大可扩到1GB。

2、线程挂起会保存线程上下文到栈空间,再切换到可执行线程,陷入内核,进行上下文切换,导致CPU开销大。协程在用户态由协程调度器完成,不需要陷入内核进行上下文切换,代价更小。其次,协程切换的时间点也是有调度器决定,尽管切换点都是时间片超过一定阈值,或者进入I/O或睡眠状态。

3、每个线程都有id,可通过id来操作某个线程(一个线程如杀死别的线程),协程没有这个概念,所以一个协程不能杀死另外一个协程。

4、基于垃圾回收机制,垃圾回收的必要条件使内存位于一致状态,因此需要暂停所有的线程使其一致,但是在go中,调度器知道什么时候内存位于一致状态,没有必要暂停所有运行的线程。

创建goroutine:

只需添加go关键字,调度器会自动将其安排到合适的系统线程上去执行。

当一个程序启动时,主函数在一个单独的goroutine中运行,称之为main goroutine,新的goroutine使用go创建。所有goroutine在main函数结束时一并结束,协程调度性能不如线程细致,其细致程度取决于调度器的实现和运行环境。终止goroutine的最好方法就是直接在函数中自然返回。

func Task(){
	for {
		fmt.Println(time.Now().Format("15:04:05"),"正在处理Task的任务")
		time.Sleep(time.Second*3)
	}
}

func main(){
	go Task()
}

上述代码运行后不会有任何输出,因为运行go Task()之后程序会回到main()函数,而main()函数后面没有任何逻辑代码,程序就会判断执行完毕,终止所有协程。可以在main()函数后面添加一些等待逻辑,如time.Sleep()

匿名函数创建goroutine
func main(){
	go func() {
		for {
			fmt.Println(time.Now().Format("15:04:05"),"正在处理Task的任务!")
			time.Sleep(time.Second*3)
		}
	}()
	time.Sleep(time.Second*100)
}
runtime包:可实现一个小型的任务调度器,可以高效将CPU资源分配给每一个任务

主要有三个函数 Cosched(), Goexit(), GOMAXPROCS()

Cosched():使当前协程放弃处理器,让其他协程运行,但不会挂起当前协程,未来会恢复执行。

Go语言的协程是抢占式调度,当遇到长时间执行或者系统调用时,会主动把当前的goroutine的CPU转让出去,让其他协程调用。如以下情况会发生调度。

syscall
C函数调用(相当于syscall)
主动调用runtime.Gosched
某个goroutine的调用时间超过100ms,并且调用了非内联的函数。

什么是内联函数?
内联函数是指当编译器发现某段代码在调用一个内联函数时,不是去调用该函数,而是将该函数的代码整段插入到当前位置。这样做的好处就是省去了调用的过程,加快程序运行速度。

func main(){
	go func() {
		for i:=0;i<3;i++{
			fmt.Println("go")
		}
	}
	for i:=0;i<2;i++{
		//runtime.Gosched()     阻止该循环获取CPU控制权
		fmt.Println("main")
	}
}
//结果为:
main
main

上述代码中由于第二个for循环一直占着CPU控制权,知道运行结束也没有发生goroutine的调度,因此第一个协程匿名函数无法控制CPU控制权,导致无法执行。

Goexit():终止调用它的Go协程,其他协程不受影响。

Goexit()会在终止该Go协程前执行所有的defer函数

func main(){
	go Task1()
	go Task2()
	time.Sleep(time.Second*10)
}

func Task1(){
	defer fmt.Println("task1 stop")
	fmt.Println("task1 start")
	fmt.Println("task1 work")
}

func Task2(){
	defer fmt.Println("task2 stop")
	fmt.Println("task2 start")
	fmt.Println("task2 work")
	runtime.Goexit()      //效果和return一样
	//fmt.Println("task2 stop")
}

在这里插入图片描述
在这里插入图片描述

GOMAXPROCS(n int):设置程序在运行中所使用的CPU数,默认使用最大CPU数进行计算。

本机机器的逻辑CPU数可通过runtime.NumCPU()查询。

channal:与map 相似,对应make创建的底层数据结构的引用

goroutine运行在相同的地址空间,因此访问共享内存必须做好同步。
channal是CSP模式的具体体现,用于多个goroutine之间通信,内部实现了同步,确保并发安全。

声明channal:

var 通道变量 chan 通道类型

初始化channal:

无缓冲通道: make(chan Type) 等价于make(chan Type,0)
有缓冲通道: make(chan Type,capacity)
当容量为0时,channal是无缓冲阻塞读写的,当容量大于0时,channal是有缓冲,非阻塞的,直到写满capicaty个元素才阻塞读写。

接收和发送数据
channal接收数据语法
发送value到channalchannal <- value
channal=发送数据语法
丢弃channal发送的数据<-channal
channal发送的数据赋值给xx := <- channal
同上,并将查通道是否关闭,将状态赋值给okx,ok := <- channal
func main(){
	ch := make(chan string)
	go func(){
		fmt.Println(<-ch)
	}
	ch <- "test"
	time.Sleep(time.Second)
}

//结果为:
test

该例子初始化一个可以传输string类型的ch通道变量,在匿名协程函数中打印ch通道接收的数据

缓冲机制:

channal按是否支持缓冲区,将通道分为无缓冲的通道和有缓冲的通道

无缓冲通道 ——接受前没有能力保存任何值的通道。
这种通道要求发送和接受goroutine同时准备好才能完成接收和发送数据,如果没准备好会造成阻塞等待。
这种对通道进行发送和接收的交互行为本身就是同步的,其中任意一个操作都无法离开另一个操作单独存在。

func main(){
	ch := make(chan)
	go func(){
		for i := 0;i < 3;i++{
			fmt.Printf("len(ch)=%v,cap(ch)=%v\n",len(ch),cap(ch))
			ch <- i
		}
	}()
	for i := o;i < 3;i++{
		time.Sleep(time.Second)
		fmt.Println(<- ch)
	}
}
//结果为:
len(ch)=0,cap(ch)=0
0
len(ch)=0,cap(ch)=0
1
len(ch)=0,cap(ch)=0
2

从上面的例子可以看出,无缓冲通道发送和接收数据是同步的,只有当channal接收到数据才能发送数据。

有缓冲通道:是接收数据前可以存储一个或多个值的通道 ,不强制要求goroutine之间同时完成接收和发送。
只有在通道中没有要接收的值时,接收动作才会阻塞;没有可用缓冲区容纳被发送的值时,发送动作才会阻塞。但是接收和发送是异步进行的,channal在接收的同时也在发送,但是如果发送的速度大于接收的速度,而此时channal没有存储数据时,发送就会阻塞。

区别:无缓冲通道保证进行发送和接受的goroutine会在同一时间进行数据交换,有缓冲的通道则不行。

func main(){
	ch := make(chan int,3)
	go func(){
		for i := 0;i < 3;i++{
			fmt.Printf("len(ch)=%v,cap(ch)=%v\n",len(ch),cap(ch))
			ch <- i
		}
	}()
	for i := o;i < 3;i++{
		time.Sleep(time.Second)
		fmt.Println(<- ch)
	}
}
//结果为:
len(ch)=0,cap(ch)=3
len(ch)=1,cap(ch)=0
len(ch)=2,cap(ch)=0
0
1
2
关闭channal

当发送者没有更多的值要发送时,为了让接收者及时知道没有更多的值需要接收,停止不必要的等待,可用过内置的close函数和range关键字实现。

使用close时注意:
1、channel不像文件需要频繁关闭,只需确定没有需要发送的数据的时候,或者显式结束range循环,才去关闭channel。
2、关闭channel,无法再次向channel发送数据,但是可以接收数据
3、对于nil channel,发送和接收都会阻塞。

func main(){
	ch := make(chan int,3)
	go func(){
		for i := 0;i < 3;i++{
			fmt.Printf("len(ch)=%v,cap(ch)=%v\n",len(ch),cap(ch))
			ch <- i
		}
		close(ch)
	}()
	//time.Sleep(time.Second)
	for {
		if val,ok := <-ch;ok{
			fmt.Println(val)
		}else{
			return
		}
	}
}
//结果为:
len(ch)=0,cap(ch)=3
len(ch)=0,cap(ch)=3  //至于这里为什么是0,我感觉ch在接收0的同时很快就发送了出去,
//因为此时chan是空的,一直在等待发送数据,导致在下一次打印到来之前已经发送出去了。
//这一点可以通过上面的注释验证,一旦让chan延迟发送数据,此时len(ch)=1
len(ch)=1,cap(ch)=3
0
1
2

range遍历channel更加简洁,当channal关闭后也会自动结束本次遍历,代替上面的return。

func main(){
	ch := make(chan int,3)
	go func(){
		for i := 0;i < 3;i++{
			fmt.Printf("len(ch)=%v,cap(ch)=%v\n",len(ch),cap(ch))
			ch <- i
		}
		close(ch)
	}()
	//time.Sleep(time.Second)
	for data := range(ch){
		fmt.Println(data)
	}
}
单向channel : 有时候希望建一个单向通道作为参数进行传递,使得数据流是固定的,比如说生产者和消费者模型。

声明:
var in chan<- int 只能向channal发送数据
var out <-chan int只能从channal接收数据

func producer(in chan<- int){
	for i := o;i < 10;i++{
		in <- i
	}
	close(in)
}

func consumer(out <-chan int){
	for val := out{
		fmt.Println(val)
	}
}

func main(){
	ch := make(chan int)  //编译器会把普通的双向channal隐式转换为单向channal,不能反过来
	go producer(ch)
	consumer(ch)
}
//结果为:
0
1
2
3
4
5
6
7
8
9
定时器time.NewTricker——通过单向channal实现的

使用定时器实现每隔一段时间打印一串字符

func main(){
	ticker := time.NewTicker(time.Second)

	for {
		<-ticker.C
		fmt.Println("loop")
	}
}
//结果为:
loop
loop
loop
...
select——监听channel上的数据流动

与switch用法相似,但相比限制更多,最大限制是每个case语句必须是一个I/O操作

在一个select语句中,Go语言会按顺序从头至尾评估每一个发送和接收语句,随机选择其中没有被阻塞的语句,若都被阻塞,执行default语句,若没有default语句,select语句被阻塞,知道有一个channal可以进行下去。

func main(){
	ch := make(chan int)
	go func(){
		for i := 0;i < 3;i++{
			ch <- i
		}
	}()
	for{
		select{
			case msg := <-ch:
				fmt.Println(msg)
			default:
				time.Sleep(time.Second)
		}
	}
}
//结果为:
0
1
2

select语句还可以实现阻塞超时机制,避免程序长时间进入阻塞。

func main(){
	ch := make(chan int)
	done := make(chan bool)
	
	go func(){
		for {
			select{
				case val := <-ch:
					fmt.Println(val)
				case <-time.After(time.Second*3):
					fmt.Println("已超时3s")
					done <- true
			}
		}
	}()
	
	for i := 0;i < 10;i++{
		ch <- i
	}
	
	<- done
	fmt.Println("程序终止")
}
//结果为:
0
1
2
3
4
5
6
7
8
9
已超时3s
程序终止

死锁:就是所有的线程或进程都在等在资源的释放

func main(){
	ch := make(chan int)
	<- ch
}

上面就是一个死锁的例子,该例子中只有一个goroutine(main goroutine),不管是向信道ch里加数据还是拿数据,都会锁死信道,并且阻塞当前goroutine,也就是所有的goroutine都在等待信道的打开,这就产生了死锁。

非缓冲信道发生了流入无流出,或者流出无流入就会死锁。
同样的,使用select关键字,不加任何代码也会死锁。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值