Go核心开发学习笔记(廿八) —— 协程goroutine & 管道Channel

问题:求1-20000之间所有的素数
传统方法: 每个数都需要做 2 <= i <= n - 1 次取模循环,如果是20亿,计算机该哭了。
Golang方法:事情肯定过程还是这个过程,如果使用并发加并行的方式,而不是传统循环,这样效率就会高很多,同时也符合分布式需求。

解决方案
使用并发或者并行的方式,让统计素数的任务分配给多个goroutine去完成,这时候就引入了协程goroutine方案。
假设1-20000的计算,按照分配成四等份,则速度明显提升,符合多核CPU,分布式集群的风格。

进程和线程

  1. 学过linux全程的人一定知道程序、进程和线程的关系,在此不赘述,大致做一些笔记
  2. 程序是对指令,数据及其组织形式的描述。
  3. 进程就是程序在操作系统中的一次执行过程,是系统进行资源分配和调度的最小单位,进程彼此处于不同的命名空间所以互不影响。
  4. 线程可以理解为进程的一个执行实例,是操作系统可以执行的最小单元,一个标准的线程由线程ID,当前指针指令,寄存器集合和堆栈组成。
  5. 进程是线程的"容器"****,线程崩了直接影响进程导致进程崩了,例如暴风影音,视频和音频同步(并发)就可以理解为是线程协作。

并发和并行

  1. 多线程程序在单核上运行,就是并发(时分复用)CPU在任务之间以毫秒级切换,看似是多个任务一起在跑,要区分web并发,web并发是真同时运行。
  2. 多线程程序在多核上运行,就是并行,真多任务同时进行,从效率来讲肯定是并行比并发效率高。

协程 goroutine

  1. 主进程是一物理进程,直接作用于CPU,重量级耗资源,每个进程至少有一个主线程。
  2. 协程是从主线程开启的轻量级线程,逻辑态,资源消耗小。
  3. Golang的协程机制是重要特点,轻松开启上万协程。
  4. Go主线程(说法有些不同,其实可以理解为进程),一个go线程上可以起多个协程,协程是轻量级线程(编译器做的底层优化)。
  5. Go协程的特点
    有独立的栈空间
    有共享的堆空间
    调度是在用户空间层面
    协程是轻量级线程

goroutine使用方法

  1. 一般都是针对函数使用,只需要在主线程执行结束前,添加 go <函数名>() 即可完成。
  2. 线程结束不管协程任务有没有执行完,最后都会强制关闭(主线程的栈空间关闭,协程没有老家了)。

案例演示:

package main

import (
	"fmt"
	"time"
)

func echo() {
	for i := 0 ; i <= 10 ; i++ {
		fmt.Println("echo() 我是练习时长两年半的xx")
		time.Sleep(500 * time.Millisecond)
	}
}

func main() {
	/*
	一般都是根据函数走的,只需要在前面添加 go <函数名>()
	 */
	go echo()      //开启协程
	
	for i := 0 ; i <= 10 ; i++ {
		fmt.Println("main() 喜欢唱,跳,rap,篮球")
		time.Sleep(time.Second)
	}
	//go echo()    //这种开启协程方式无效,因为主函数已经结束了,充分理解原理

}   //输出效果可以echo()和main()中是同时执行的(for循环表现为穿插)

MPG goroutine调度模型

M:直接关联了一个内核线程 KSM <—> M,多个M运行在一个CPU上就是并发,多个M运行在多个CPU上就是并行。
P:上下文,G和M之间沟通的桥梁,P中存在runqueue,队列里存在多个G。
G:就是协程Goroutine,轻量级线程,并且是逻辑态,Go可以轻松起上万个协程。

相比于Java和C的多线程,一般都是内核态,比较重量级,每个线程大概8M,几千个线程就可能耗光CPU;
goroutine资源消耗极低,每个协程大概*2k

运行模型:M-P-G

  1. P中的G是个runqueue,P以pop方式挨个处理队列中的G。
  2. 队列中的一个G如果是读取大文件造成协程阻塞后,所在的M0会创建另外一个M,假设为M1(与操作系统有关,线程池),将P挂载到M1下继续pop其中的G;M0继续被G0阻塞。
  3. MPG调度模式,既可以让协程执行,也不会让队列中的其他协程一直阻塞,仍然可以并发/并行执行。

Golang设置运行CPU个数

扫描二维码关注公众号,回复: 10310316 查看本文章
package main

import (
"fmt"
"runtime"
)

func main() {
	num := runtime.NumCPU()      //显示当前系统逻辑CPU个数
	fmt.Println(num)
	fmt.Println(runtime.GOMAXPROCS(num - 1))  //设置可同时执行的CPU最大个数
	// 这个在Golang1.8之后不用在设置了
}

Golang中对应协程并发/并行出现的几个问题

  1. 如何解决并发/并行 同时写操作的问题。
  2. 如何避免主程序没等到协程执行完就退出完成执行的情况
  3. 不同goroutine之间通信如何通信:
    全局变量互斥锁
    使用管道channel

举例说明全局互斥锁:用协程方式处理1-10所有阶乘的结果,输出结果如下: 1的阶乘结果为1,2的阶乘效果为2…

package main

import (
	"fmt"
	"sync"
)
var FactMap = make(map[int]int,10)
var lock sync.Mutex   //变量是一个结构体变量,有两个方法 lock() unlock()

func Factorial(n int) {
	res := 1
	for i := 1 ; i <= n ; i++ {
		res *= i
	}
	lock.Lock()
	FactMap[n] = res   //防撞写,使用lock
	lock.Unlock()
}

func main() {
	/*
	用协程方式处理1-200所有阶乘的结果,输出结果如下: 1的阶乘结果为1,2的阶乘效果为2...
	把上述结果加入一个map中
	思路分析:
	类似排队上厕所,如果坑里有人,那么就直接加锁,外面的人找个队列候着
	 */
	for i := 1; i <= 10 ; i++ {
		go Factorial(i)
	}
	fmt.Println(FactMap)
}

为何使用管道channel

  1. 使用全局变量锁同步来解决goroutine通信问题不完美。
  2. 主程序在等待所有goroutine全部完成的时间很难确定,需要一个类似defer一样的关键字来界定完成时间。
  3. 利用sleep控制主线程时间不靠谱,多了少了都不合适。
  4. 加锁位置需要分析,太复杂,有些时候少加,加错都会出现各类问题。
  5. ★★★优质代码精髓:不要通过共享内存来通信,而要通过通信来共享内存!!!!!

管道channel的介绍

  1. 本质是一种数据结构–队列,FIFO:先进先出,Stack:先进后出。
  2. 数组,切片后续都可以实现管道,实现管道的方式除了队列还有链表等。
  3. 管道容量是存在上限的,所有队列中的内容都出去后,管道就空了。
  4. 线程安全,多goroutine访问时不需要加锁,解决了撞写问题
  5. channel本身是有类型的,一般不建议混用,如果混用,取值需要类型断言

管道channel声明:
var <管道变量名称> chan <数据类型>
var intChan chan int
var stringChan chan string
var infChan chan interface{}
var mapChan chan map[int]string

stringChan = make(chan string , 10) // 10个字符串的string管道

  1. channel是引用类型。
  2. channel必须make初始化才能使用
  3. 管道是有类型的,如果混用则把管道定义为空接口模式。
  4. 如果管道定义成空接口,那么变量取值时一定要使用类型断言,因为默认传进去的数据都会被认为是接口类型。
  5. 使用close()可以关闭管道,关闭后只能读不能写,相当于进向截流了
  6. 定义channel可以是只读或者只写,相当于单项传输,
    var intChan1 chan<- int //只写管道
    var intChan2 <-chan int //只读管道

管道channel使用案例,通过案例理解管道

package main
import "fmt"

func main() {
	var stringChan chan string
	fmt.Println(stringChan)        //nil  符合引用类型不开辟内存栈就是nil
	stringChan = make(chan string,3)  //管道容量为3
	fmt.Println(stringChan)        //0xc000038060 发现是一个地址
	fmt.Println(&stringChan)       //0xc000082018 这个是管道变量的地址,也就等于说管道就是个指针变量,所以支持多个协程操作管道

	//向管道写数据
	stringChan<- "蔡徐坤"          // \管道变量名称\<- string 
	str := "鸡你太美"
	stringChan<- str              // 

	//查看管道容量len() cap()
	fmt.Printf("%v %v", len(stringChan),cap(stringChan))  //len是值目前管道中使用容量 2 ,cap是指管道总容量 3 
	//注意管道一定不能超,超了容量所有的goroutine都会deadlock,管道的价值在于边放边取
	//在不使用goroutine的情况下,管道中的数据取完了,也会报deadlock,注意这些。
	
	//从管道取出1个元素,管道的len()会减1
	var _ string
	_ = <-stringChan   //取出一个元素
	fmt.Printf("%v %v", len(stringChan),cap(stringChan))   //len是值目前管道中使用容量 1 ,cap是指管道总容量 3

	//还剩1个元素,鬼畜一般再取2次
	<-stringChan           //还剩0个,直接扔 <-stringChan
	//_ = <-stringChan         //deadlock
	fmt.Printf("%v %v", len(stringChan),cap(stringChan))

	var infChan chan interface{}
	infChan = make(chan interface{},3)
	infChan <-"唱,跳,rap,篮球"
	infChan<- 666
	rev := <-infChan      //唱,跳,rap,篮球
	fmt.Println(rev) 
	close(infChan)        //关闭信道,这样里面还剩下 666 int,不可以再接收了,只能再把666读出来了。
	fmt.Println(len(infChan))  //1
	infChan<- "shit!"   // panic: send on closed channel
}

关于管道关闭和遍历

  1. 如果管道没有关闭,则会出现deadlock报错,因为遍历一个管道len会减1,这样相当于遍历到n/2 +1的时候,直接没有数据了,死锁了,但是所有遍历的数据都在
  2. 如果遍历管道,必须要关闭管道,正常遍历数据,遍历完成就会退出
    package main
    
    import "fmt"
    
    func main() {
    	var intChan chan int
    	intChan = make(chan int , 100)
    
    	for i := 1 ; i <= 100 ; i++ {
    		intChan<- i
    	}
    	//没有加close(intChan)就会报错
    	close(intChan)
    	for v := range intChan {
    		fmt.Println(v)
    	}
    }
    

根据前面协程和管道知识,处理综合案例

1. 管道学习完成是为了解决之前主线程完成直接结束程序,不会等待协程。

package main

import "fmt"

func writeData(intChan chan int) {
	for i:=1;i<=50;i++ {
		intChan<- i
	}
	close(intChan)    //后续就可以用for循环读了
}

func readData(intChan chan int,exitChan chan bool) {
	for {
		v, ok := <-intChan   //如果管道中没有数据了,ok默认为true,会变为false
		if !ok {             //
			break
		}

		fmt.Printf("读到数据为:%v",v)
	}
	exitChan<- true
	close(exitChan)
}

func main() {
	/*完成协同工作案例
	1. writeData协程,向管道写如50个int
	2. readData协程,负责从管道中读取writeData写入的数据
	3. 上述两个协程操作同一个管道
	4. 主线程要等待上述两个协程都完成后,才能结束
	 */
	intChan := make(chan int,50)
	exitChan := make(chan bool,1)
	go writeData(intChan)
	go readData(intChan,exitChan)
	
	//接下来保证协程结束后,才结束主线程
	for {
		<-exitChan
		//_, ok := <-exitChan    //反复读取,直到读空
		//if !ok {
		//	break
		//}
	}
}

2.利用协程和管道完成1到100之间所有的素数取值

package main

import "fmt"

func inNum(intChan chan int) {
	for i := 3; i <= 100 ; i++ {
		intChan<- i
	}
	defer close(intChan)
}

func outChan(intChan chan int,primeChan chan int,exitChan chan bool) {
	for {
		num, ok := <-intChan
		if !ok{         //管道里面取不到东西了,凡是赋值变量取所有管道内数据都是用这种方法。
			break
		}
		//判断num是不是素数
		for i := 2; i <= num ;i++ {
			if num % i == 0 && i < num {
				//不是素数不要
				break
			}
			if num == i {
				primeChan<- i
			}
		}
	}
	exitChan<- true

}

func main() {
	intChan := make(chan int,2000)
	primeChan := make(chan int,2000)
	exitChan := make(chan bool,4)

	start := time.Now().Unix()
	//协程 放入数据
	go inNum(intChan)    //defer 直接后续关闭intChan管道

	//开启四个协程,从intChan里面取出数据,并判断是否为素数,是就放入primeChan中
	for i := 0 ; i <= 3 ; i++ {
		go outChan(intChan,primeChan,exitChan)
	}

	for i := 0; i < 4 ; i++ {
		<-exitChan             //当收到管道中有四个true时则说明协程已经取完了primeChan中所有的内容
	}
	close(primeChan)           //所以可以关闭管道

	end := time.Now().Unix()
	fmt.Println("使用协程耗时为:",end - start)   //查看协程节省多少时间,结果确实证实可以提高效率
	
	for {
		res, ok := <-primeChan    //遍历primeChan中所有的素数
		if !ok {
			break
		}
		fmt.Println("素数为:",res)
	}
}

3. 不知道管道何时关闭,可以使用select解决管道阻塞问题:

package main

import "fmt"

func main() {
	/*
	select语句,依次向下执行,解决阻塞问题,也不用考虑何时需要close(varchan)了
	 */
	var intChan = make(chan int, 10)
	var strChan = make(chan string,10)
	for i := 0 ; i < 5 ; i++ {
		intChan<- i
	}
	for i := 0 ; i < 5 ; i++ {
		strChan<- "shit" + fmt.Sprintf("%d",i)
	}

	//close(intChan)           //其中传统管道关闭方法
	//for v:= range intChan {
	//	fmt.Println(v)
	//}
	
	for {
		select {
		case v := <-intChan:
			fmt.Println(v)
		case s := <-strChan:
			fmt.Println(s)
		default:
			fmt.Println("不玩了继续下面了")
			return   //跳出整个for循环而不是select,也可以使用label...break方式来结束for循环从而终止main()		
		}
	}
}

4. 如果有一个函数出现了panic,如何避免辅函数panic导致主函数无法继续进行,使用 defer + recover 。

package main

import (
	"fmt"
	"time"
)
var strChan = make(chan string,100)

func test1() {
	for i := 0 ; i < 10 ; i++ {
		strChan<- "鸡你太美" + fmt.Sprintf("%d",i)
	}
	close(strChan)
	for {
		v, ok:= <-strChan
		if !ok {
			break
		}
		fmt.Println(v)
	}

}

func test2() {
	/*匿名函数捕获错误,不要影响其他协程和主线程运行*/
	defer func() {
		if err := recover();err != nil {
			fmt.Println("test2()协程发生错误是:",err)
		}
	}()

	var shitMap map[int]string   //这里没有为shitMap分配一个栈,肯定会报错
	shitMap[1] = "蔡徐坤"        //而这个协程阻止了主线程和另外一个协程的运行如果避免则需要加入defer+recover捕获错误
}

func main() {
	go test1()
	go test2()

	time.Sleep(10 * time.Second)
}

运行结果:会输出test1的内容,test2的错误,并且主程序等待10秒后关闭。

发布了49 篇原创文章 · 获赞 18 · 访问量 3997

猜你喜欢

转载自blog.csdn.net/weixin_41047549/article/details/90548470