go语言并发编程(上)

go语言当中的协程

在java/c++中我们要实现并发编程的时候,我们通常需要自己维护一个线程池,并且需要自己去包装一个又一个的任务,同时需要自己去调度线程执行任务并维护上下文切换,这一切通常会耗费程序员大量的心智。那么能不能有一种机制,程序员只需要定义很多个任务,让系统去帮助我们把这些任务分配到CPU上实现并发执行呢?

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

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

在这里插入图片描述

MPG模型介绍

下面我们来解释一下MPG模式当中的M、P、G分别代表什么意思

  • M:操作系统当中的线程(物理线程/内核线程)
  • P:调度器负责调度协程,其维护了一个协程队列,M从P当中获取到协程并执行
  • G:协程每个协程都有自己的栈空间定时器

在这里插入图片描述
其模型大致如下图所示:
在这里插入图片描述
在这里,我们看到2个线程(M),每个线程都有一个上下文(P),每个线程都运行一个goroutine(G)。
灰色的goroutine尚未运行,但已准备好进行调度。 它们被排列在称为运行队列的列表中。 每当Goroutine执行go语句时,Goroutine就会添加到运行队列的末尾。 上下文在一个调度程序中运行了一个goroutine直到一个调度点后,它会从其运行队列中弹出一个goroutine,设置堆栈和指令指针并开始运行goroutine。为了减少互斥锁争用,每个上下文都有其自己的本地运行队列。 Go调度程序的早期版本仅具有全局运行队列,并带有互斥量来保护它。 线程经常被阻塞,等待互斥锁解锁。只要所有上下文都有要运行的goroutine,调度程序便会在此稳态下继续调度。但是在这一下两种情况下情况不同

  • 调用了系统调用
  • 本地队列运行完毕

当我们执行系统调时时需要进入内核态,阻塞的时候,由于一个线程在被阻塞的时候,不能执行其他的代码。也就是当一个G调用系统调用占住了M,那么M此时不能再执行P维护的队列里面的其他的G了。但是这个时候我们可以把P调度到其他的M上面继续运行
在这里插入图片描述
在这里,M0放弃了P,所以其他的M可以运行P。M1可能是新创建的,也可能是已经存在的。

当系统调用返回的时候,线程必须尝试获取上下文才能运行返回的goroutine(原因就是上面说的,线程必须拥有一个上下文才能运行Goroutine)。正常的操作模式是从其他线程之一偷上下文。如果偷不到,它将把goroutine放在全局运行队列中,将自身放在线程缓存中并进入睡眠状态。

全局运行队列是上下文从本地运行队列用尽时开始提取的运行队列。上下文还定期检查全局运行队列中的goroutine。否则,由于饥饿,全局运行队列上的goroutine可能永远无法运行。到这里,也就明白了为啥Go的线程模型不是N:1,而是M:N。
N:1的缺点就是某一个线程进行系统调用内核线程的时候阻塞了,会导致其他绑定在这个内核线程的N-1个线程也会阻塞。而Go的M:N模型通过将上下文调度到其他的内核线程上继续运行来解决了这个问题。所以是M:N。

情况二:
如果本地队列运行完毕
如果上下文的运行队列上的工作量不平衡,假设P1队列上有2个Goroutine,但是P2队列上面有20个Goroutine。这个时候P1可能很快就运行完了自己队列里面的Goroutine。此时,P1可以从全局队列里面去取Goroutine,但是假如这个时候全局队列里面没有了Goroutine呢?
在这里插入图片描述
如果全局队列里面没有Goroutine的话,它将尝试从另一个上下文中偷大约一半的运行队列。这样可以确保所有线程都在最大容量下工作。

设置golang运行时的CPU数

为了充分了利用多cpu的优势,在Golang程序中,设置运行的cpu数目。注意在go1.8后,默认让程序运行在多个核上,可以不用设置了.而在go1.8前,还是要设置一下,可以更高效的利益cpu,我们可以通过runtime包里面的相关函数来设置这个相关值。下面给出一段代码进行演示

package main

import (
	"fmt"
	"runtime"
)

func main() {
	CpuNum := runtime.NumCPU()
	//获取逻辑CPU数
	fmt.Println("CPU核数为:", CpuNum)
	num := runtime.GOMAXPROCS(CpuNum)
	// GOMAXPROCS设置可同时执行的最大CPU数,并返回先前的设置
	fmt.Println("原来设置的CPU数为:", num)
}

协程的使用及协程安全

1.下面我们通过一个案例来入门一下这个协程。
1.在主线程(可以理解成进程)中,开启一个goroutine,该协程每隔1秒输出"hello,world"
2.在主线程中也每隔一秒输出"hello,golang",输出10次后,退出程序
3.要求主线程和goroutine同时执行

对应代码

package main

import (
	"fmt"
	"time"
)

func Print() {

	for i := 0; i < 10; i++ {
		fmt.Println("Test(): Hello world")
		time.Sleep(time.Second)
	}
}
func main() {

	go Print()
	//创建了一个协程让他去跑Print()函数

	for i := 0; i < 10; i++ {
		fmt.Println("Main():Hello world")
		time.Sleep(time.Second)
	}

}

2.GOROUTINE的并发安全提出
如果之前学过这个其它语言的并发编程的话相信这一块,还是非常的容易理解的。当多个执行流都能看到的资源我们叫做临界资源,此时就有可能出现这个线程安全的问题。下面我们通过一个案例引出这个问题
需求:现在要计算1-200 的各个数的阶乘,并且把各个数的阶乘放入到map中。最后显示出来。
要求使用goroutine完成。
分析在这里就有一个问题,这个map是一个临界资源多个执行流都能看到,多个执行流同时对map进行修改可能会出现数据不一致的问题,下面直接给出代码如何写这个代码

package main

import (
	"fmt"
	"sync"
)

var (
	myMap = make(map[int]int, 10)
	mtx   = sync.Mutex{}
	//互斥锁
)

func Print(n int, NoticChan chan bool) {

	res := 1
	for i := 2; i <= n; i++ {
		res *= i
	}
	mtx.Lock()
	myMap[n] = res

	mtx.Unlock()
	NoticChan <- true
	//执行完毕之后向管道当中写入数据

}

func main() {
	NoticChan := make(chan bool, 10)
	for i := 1; i <= 10; i++ {
		go Print(i, NoticChan)
	}

	for {
        //当管道当中数据个数为10个说明这个10个协程执行完毕主线程可以不要等待了
		if len(NoticChan) == 10 {
			for k, v := range myMap {
				fmt.Printf("key:%d value:%d\n", k, v)
			}
			break
		}
	}

}

在这里我们引入了一个新的关键字chan,用来定义管道。我们在后面详细的介绍这个

管道CHANNEL

管道其本质上是一个环形队列,在这里说明一下定义管道有以下节点需要注意

1.hannel本质就是一个数据结构-环形队列
2.数据是先进先出[FIFO : [first in first out]
3.线程安全,多goroutine访问时,不需要加锁,就是说channel本身就是线程安全的(编译器底部维护的)
4.channel有类型的,一个string的channel只能存放string类型数据

在这里插入图片描述

下面我们来看看如何定义管道

    var intChan chan int //(intChan用于存放int数据)
	var mapChan chan map[int]string// (mapChan用于存放map[int]string 类型)
	var perChan chan Person //用于存放Person自定义类型
	var perChan2 chan *Person

在这里有以下几点需要注意

  • channel是引用类型,需要初始化后才能写入数据。也就是通过make
  • 管道是有类型的,管道的类型是什么就只能写入这种类型的数据
  • 当管道满了之后,在没有协程的情况下。再次写入没造成死锁
  • 在没有使用协程的情况下(取完没放入),如果channel数据取完了,再取,就会报deadlock
  • 在遍历管道前需要提前将管道关闭,否则遍历时会同样的会造成死锁

下面通过一段代码进行演示

package main

import "fmt"

func main() {

	ch := make(chan int, 5)
	//创建一个管道
	for i := 0; i < 5; i++ {
		ch <- i
		//往管道里面写入数据
	}

	for len(ch) > 0 {
		value := <-ch
		fmt.Println(value)
	}

	ch = make(chan int) //非缓冲通道
	val := <-ch
	fmt.Println(val)
	//注意channel关闭之后不能向channel当中写入数据,否则会造成死锁
	/*
	   channel支持for --range遍历但是请注意两个细节
	   遍历时如果channel没有关闭则会出现deadlock的错误
	   在遍历时如果channel以及关闭了则会正常的遍历数据,遍历完毕之后就会退出吧遍历
	*/

	intchan := make(chan int, 100)
	for i := 0; i < 100; i++ {
		intchan <- i
		//放入100个数据到channel当中
	}

	//for i := 0; i < len(intchan); i++ {
	//	fmt.Println(<-intchan)
	//	//注意这样会少50个数据所以不能这样遍历管道的长度是一直在变的
	//}

	close(intchan) //遍历其一定需要将管道关闭否则会造成死锁
	for v := range intchan {
		fmt.Println(v)
	}

}

Channel的遍历和关闭

1.channel的关闭:使用内置函数close可以关闭channel,当channel关闭后,就不能再向channel写数据了,但是仍然可以从该channel读取数据。
2.channel支持for-range 遍历和普通for进行遍历,但是普通的for循环遍历, 因为取出操作本身会导致长度变化所以我们不建议使用。
3.在遍历时,如果channel没有关闭,则回出现deadlock的错误。在遍历时,如果channel已经关闭,则会正常遍历数据,遍历完后,就会退出遍历。
上面这几点是我们在使用管道的过程当中特别需要注意的,一不小心就造成了死锁问题

package main
import(
	"fmt"
)

func main(){
	intchan := make(chan int, 3)
	intchan <- 10
	intchan <- 20
	// 关闭管道
	close(intchan)
	// 关闭后不能在存放
	// intchan <- 30
	// 读取数据完全没有问题
	n1 := <-intchan
	<- intchan
	fmt.Println(n1)
	
	intchan2 := make(chan int, 100)
	for i := 0; i < 100; i++{
		intchan2<- i*2
	}
	//	遍历管道不能使用普通的for循环 因为一个取出后第二个就变成第一个位置啦
	// for i := 0; i < len(intChan2); i++ {
	// }
	// 遍历前需要先关闭管道
	close(intchan2)
	for v := range intchan2{
		fmt.Println(v)
	}
}

使用管道实现生产者消费者模型

1.开启一个writeData协程,向管道intChan中写入50个整数
2.开启一个readData协程,从管道intChan中读取writeData写入的数据。
3.注意: writeData和readDate操作的是同一个管道
4.主线程需要等待writeData和readDate协程都完成工作才能退出[管道]

在这里插入图片描述

对应代码

package main

import (
	"fmt"
	"time"
)

/*开启一个goroutine和channel协同完成工作要求
  开启一个writeData协程向管道里面写入50个数据
  开启一个readData协程从管道当中读取数据
  主线程需要等待writeData和readData读取完毕之后才能退出
*/

func WriteData(intChan chan<- int) {
	for i := 0; i < 50; i++ {

		fmt.Println("写入数据:", i)
		intChan <- i
	}
	close(intChan)
	//不影响读取

}

// ReadData 只读管道
func ReadData(intChan <-chan int, exitChan chan bool) {
	for {
		v, ok := <-intChan
		if !ok {
			break
		}
		fmt.Printf("readData 读取到数据=%v\n", v)
		time.Sleep(time.Second)
	}
	//任务完成向管道当中写入数据
	exitChan <- true
	//通知主线程工作完成
	close(exitChan)
}
func main22() {

	intChan := make(chan int, 10)
	exitChan := make(chan bool, 1)
	//用来判断是否退出
	go WriteData(intChan)
	go ReadData(intChan, exitChan)
	//注意如果只有写没有读当管道容量满了就会死锁,如果编译器发现一个管道只有写没有读则该管道阻塞
	//读管道和写管道频率不一致无所谓
	for {
		_, ok := <-exitChan
		//判断退出管道是否有数据如果是有了说明这个生产者和消费者都已经工作完毕
		if ok {
			break
		}
	}
}

在这里需要注意的几个点是这个

  • 写管道和读管道的频率不一致,无所谓。(编译器会自己分析,有没有在读没有报错死锁,有正常运行)
  • 如果,编译器(运行),发现一个管道、只有写,而没有读,则该管道,会阻塞。
  • 如果只是向管道写入数据,而没有读取,就会出现阻塞而dead lock,原因是intChan容量是10,而代码writeData会写入50个数据因此会阻塞在writeData的 ch<- i

Channel一些使用细节和注意事项

1.channel可以声明为只读管道或者只写管道。
2.默认情况下管道是双向的既可以读也可以写
下面我们通过这个代码来看看channel如何声明为只读管道或者只写管道。

package main
import(
	"fmt"
)

func main(){
	//管道可以声明为只读或者只写
	//1.在默认情况下下,管道是双向
	//var chan1 chan int //可读可写
	//2.声明为只写
	var chan2 chan<- int
	chan2 = make(chan int, 3) 
	chan2<- 20
	//num := <-chan2 //error
	fmt.Println("chan2=", chan2)
	//3.声明为只读 代码上没问题 但是没办法写怎么读 会报错
	var chan3 <-chan int
	num2 := <- chan3
	//chan3<- 30 //err
	fmt.Println("num2", num2)
}

这个的有什么引用场景了,我们可以看看前面那个例子,一个是写协程,一个是读协程这不是正好是可以套上了吗?非常的完美

2.传统的方法在遍历管道时,如果不关闭会阻塞而导致deadlock问题,在实际开发中,可能我们不好确定什么关闭该管道可以使用select方式可以解决。下面我们来看一段代码来看看这个如何使用select来达到这个目的

package main
import(
	"fmt"
	"time"
)

func main(){
	//使用select可以解决从管道取数据的阻塞问题
	//1.定义一个管道10个数据int
	intChan := make(chan int, 10)
	for i := 0;i < 10; i++{
		intChan<- i
	}
	// 2. 定义一个stringChan 存放5个string数据
	stringChan := make(chan string, 5)
	for i := 0;i < 5; i++{
		stringChan<- "hello" + fmt.Sprintf("%d", i)
	}
	//传统的方法在遍历管道时,如果不关闭会阻塞而导致deadlock
	//问题,在实际开发中,可能我们不好确定什么关闭该管道.
	//可以使用select方式可以解决
	//label:
	for{
		select{
			//注意:这里,如果intChan一直没有关闭,不会一直阻塞而deadlock
			//,会自动到下一个case匹配
				case v := <-intChan :
					fmt.Printf("从intChan读取的数据%d\n", v)
					time.Sleep(time.Second)
				case v := <-stringChan :
					fmt.Printf("从stringChan读取的数据%s\n", v)
					time.Sleep(time.Second)
				default:
					fmt.Printf("都取不到了, 不玩了, 程序员可以加入逻辑\n")
					time.Sleep(time.Second)
					return
					//break label
		}
	}	
}

3.某个协程出现问题

在go语言当中如果某个协程出现了异常,如果我们不做任何处理那么就会导致整个程序崩溃掉。在go语言当中我们可以使用 defer+recover来处理整个异常。具体如何处理我们可以通过一段代码来演示。
说明:如果我们起了一个协程,但是这个协程出现了panic,如果我们没有捕获这个panic,就会造成整个程序崩溃,这时我们可以在goroutine中使用recover来捕获panic,进行处理,这样即使这个协程发生的问题,但是主线程仍然不受影响,可以继续执行。

package main

import (
	"fmt"
	"time"
)

/*
   go语言当中使用recover解决协程当中出现的panic导致程序崩溃的问题
   如果一个协程出现了异常会导致整个程序崩溃,此时我们需要使用recover来捕获这个panic这样就不会影响其它协程

*/

func Say() {
	for i := 0; i < 10; i++ {
		fmt.Println("hello world")
	}
}

func Test() {
	//使用defer+recover捕获抛出的panic
	defer func() {
		if err := recover(); err != nil {
			fmt.Println("test()协程发生错误:\n", err)
		}
	}()
	var myMap map[int]string //需要提前make
	myMap[0] = "提升和"         //没有提前make

}
func main() {

	go Test()
	go Say()

	for i := 0; i < 2; i++ {
		fmt.Println("main() ok=", i)
		time.Sleep(5 * time.Second)
	}
}
  • 7
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 3
    评论
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

一个追梦的少年

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值