这是我参与「第五届青训营 」伴学笔记创作活动的第4天。
Goroutine
操作系统知识回顾
1.进程是程序在操作系统中的一次执行过程,是系统进行资源分配的基本单位;
2.线程是进程的一个执行实例,是CPU调度的基本单位,是程序能独立执行的最小单元;
3.一个进程可以创建和销毁多个线程,同一个进程中的多个线程可以并发执行;
4.一个程序至少有一个进程,一个进程至少有一个线程。
并发与并行
1.多线程程序在单核上运行,就是并发,即轮换执行,但轮换的很快,体现到人的角度就像是在同时执行;
2.多线程程序在多核上运行,就是并行,即同时执行,效率高。
基本介绍
goroutine(协程)是一个轻量级的线程,系统开销只有4KB左右,在处理一些十分耗费系统资源的问题时,比如涉及到很大的计算量,可以将该问题分给多个goroutine去并行完成,由此提高了CPU的利用率与程序的性能。
其特点有:
- 有独立的栈空间;
- 共享程序堆空间;
- 调度(开启、结束)由用户控制;
- 协程是轻量级的线程。
在Go的语法中开一个协程十分方便:go 要开启的任务便能起一个协程。
如以下代码开启一个协程:
func test() {
for i := 1; i < 10; i++ {
fmt.Println("IN TEST, Hello,World" + strconv.Itoa(i))
time.Sleep(time.Second) // 休眠一个时间单位
}
}
func main() {
go test() // 开启了一个协程,该协程不会因主程序运行而阻塞
for i := 1; i < 10; i++ {
fmt.Println("IN MAIN, Hello,World" + strconv.Itoa(i))
time.Sleep(time.Second) // 休眠一个时间单位,匹配协程所用的时间防止提前销毁协程
}
}
goroutine调度模型
MPG模型
- M(Machine):操作系统的内核线程(物理线程),真正干活的人。
- P(Processor):协程执行需要的上下文资源环境,类似于一个局部的调度器,使go代码在一个线程上跑,是实现N:1到N:M的映射。
- G(Goroutine):协程,多个协程会形成队列,用于调度。
状态1
说明:
- 当前程序有三个M,如果三个M都在一个CPU上运行,就是并发,不同CPU上为并行;
- 每一个M都有一个协程队列,且时刻都在执行一个G;
- go协程是逻辑态,而其他往往为内核态,几千个线程可能耗光cpu。
状态2
- 如当M0的G协程阻塞了,比如读取文件或者数据库等,且另有3个协程在队列中等待;
- 这时就会创建M1主线程(也可能是从已有的线程池中取出M1),并且将等待的3个协程挂到M1下开始执行,M0主线程下的G仍然执行造成堵塞的操作;
- 这样的MPG调度模式,可以既让阻塞的G执行,同时也不会让队列中的协程一直独立,仍然可以做到并发/并行执行;
- 等到G不阻塞了,M0会被放到空闲的主线程继续执行(从已有的线程池中取),同时G又会被唤醒。
Go拥有如此高的性能,天然支持高并发,很大一部分缘由正是因为该高效的MPG模型运作机制,具体的内容大家可以去查阅相关文献作深层了解~
Go设置运行的CPU数
runtime.NumCPU() -- > 返回本地机器的逻辑CPU个数;
runtime.GOMAXPROCS() --> 设置可同时执行的最大CPU数,并返回先前的设置,若n < 1,就不会更改当前设置。
注意:
Go1.8后,默认让程序运行在多个核上,可以不用设置;
Go1.8前,还是要设置一下,可以更大程度的利用好CPU~
多协程共同工作所会出现的问题
协程与Go主线程
Go主线程(可以理解为进程),一个go线程上,可以起多个协程,可以将协程理解为轻量级的线程(编译器做优化),但协程类似于守护线程,当程序的主线程结束任务退出后,所有的goroutine即使没有完成任务,也会被立即销毁,造成程序的错误、与预期的不一致。
对于这个问题,我们可以初步通过在让主线程睡一会来解决:
计算1 - 200的各个数的阶乘,将将计算结果放入map中:
var (
myMap = make(map[int]int, 10)
)
func test1(n int) {
res := 1
for i := 1; i <= n; i++ {
res *= i
}
myMap[n] = res
}
func main() {
// 开启多个协程来完成任务
for i := 1; i <= 200; i++ {
go test1(i)
}
// 休眠10s,防主线程先于协程关闭
time.Sleep(time.Second * 10)
// 查看结果
for i, v := range myMap {
fmt.Printf("map[%v]=%v\n", i, v)
}
}
这样虽然可以既利用到Goroutine的高效率,又解决了并行/并发导致的安全问题(如:fatal error: concurrent map writes),但我们不可能每次都准确估算、或者运行前测试一遍所有协程都完成任务需要多少时间,让主线程睡一会,会使我们程序的效率下降。
且在上述的代码中,我们所期望实现的功能是开启多个协程,计算其所拿到的数字的阶乘,并写入到创建的map中去,但有时会出现一个协程在写入时发现已经有一个协程在写入了(格林公式.dog),会报fatal error: concurrent map writes,即多个线程在竞争资源,导致程序崩溃, 出现线程安全问题。
此时,我们可以通过一些方法让协程之间、协程与主线程可以进行通信,约定好一个时间,保证大家的任务都能完成, 通信的方式有:
- 全局变量加锁同步(通过共享内存实现通信)
- channel(通过通信来共享内存)
这里我们先介绍第一种通信方法,解决的是我们抛出的第二个关于竞争的问题,channel方法(也是Go中所更为提倡的)以及效率问题的解决放到下一章节介绍。
全局变量加锁同步通信
此方法来改进运行效率的大致原理是这样的:
- 因为没有对全局变量map加锁,因此会出现资源争夺问题,如提示concurrent map writes
- 在程序中加入互斥锁,使得抢到锁的协程才能进行操作
- 如果锁正在被占用,其他协程会进入到等待队列中
比如:
var (
myMap = make(map[int]int, 10)
// 声明一个全局的互斥锁
lock sync.Mutex
// lock 为一个全局的互斥锁
// synchornized 同步;Mutex 互斥
)
func test1(n int) {
res := 1
for i := 1; i <= n; i++ {
res *= i
}
// 加锁
lock.Lock()
myMap[n] = res
// 解锁
lock.Unlock()
}
func main() {
// 开启多个协程来完成任务
for i := 1; i <= 200; i++ {
go test1(i)
}
// 休眠3s
time.Sleep(time.Second * 3)
// 查看结果
for i, v := range myMap {
fmt.Printf("map[%v]=%v\n", i, v)
}
}
可见,我们设置了一个锁使得当一个协程在写入map时,其他协程只能处于等待状态(其余所有协程处于协程队列中),待锁释放后抢到锁才能进行写入,由此解决了由于资源竞争而导致程序崩溃的问题(这里也可能直接将map变为同步sync.Map解决), 通过开启多个协程更高效的解决了计算量较大的问题。(太大数的阶乘用int肯定溢出了..希望大家可以关注到锁的使用上来.dog)
Channel
Channel的意义
- 使用全局变量加锁同步虽然可以解决goroutine通信,但主线程在等待所有goroutine全部完成的时间是很难确定的,尤其在计算量非常大时,有可能造成协程任务未完成而因主线程关闭被提前销毁
- 使用全局变量加锁同步通信,不利于多个协程对全局变量的读写操作
- 所以,用channel!
基本语法
声明:
var 变量名 chan 数据类型
如:
var intChan chan int (存放整型数据)
var mapChan chan map[int]string (存放map)
var perChan chan Person (存放结构体)
初始化:
使用make进行初始化
intChan = make(chan int, 10)
// 注意也可以不指定容量,则会为一个无缓冲通道
// 无缓冲通道会出现使通信间的协程同步的问题
写入数据:
// 向管道写入数据
intChan <- 10
num := 211
intChan <- num
// 注意管道的长度固定的,不能像slice和map那样自动增长
取出数据:
var num2 int
num2 = <-intChan
fmt.Println(num2)
管道堵塞:
如果编译器在运行时发现一个管道只有写而没有读,则该管道会造成阻塞,但如果是写的快而读的慢则无所谓,在使用管道时要注意这一点哦,也就是说,推荐建立消费者管道时加上缓冲区,由此就不用担心消费者的消费速度大于生产者的生产速度了。
注意事项
- channel是引用类型
- channel必须初始化后才能写入数据,即make后才能使用
- channel的数据放满后,就不能再放入了
- channel的本质是一个队列,达到容量后如果取出了数据,就可以即刻放入数据
- 在没有使用协程的情况下,如果我们的管道数据已经全部取出,再取就会报告 deadlock
- <-num 为将管道中的数据取出并直接舍弃
- channel是线程安全的,当多goroutine访问同一个channel时,不会发生资源竞争问题,故不需要加锁,因此其本身线程安全
- channel是有类型区分的,一个string的channel只能存放string类型数据,不过我们可以通过空接口来实现多种类管道 比如:
type Cat struct {
Name string
Age int
}
// 以下代码在main中
var allChan chan interface{}
allChan = make(chan interface{}, 3)
allChan <- 10
allChan <- "Jack&Rose"
cat := Cat{"Tom", 4}
allChan <- cat
<-allChan // 取出数据并舍弃
<-allChan
newCat := <-allChan
fmt.Printf("种类为%T,值为%v", newCat, newCat)
realCat := newCat.(Cat) // 使用类型断言
fmt.Println(realCat.Name)
Channel的关闭与遍历
Channel关闭
使用内置函数close可以关闭channel,关闭后不能再向其中写入数据,但是还可以读取数据。
close(管道名)
Channel遍历
channel支持for-range的方式进行遍历,需要注意的是:
在遍历时,如果channel没有关闭,则会出现deadlock错误
intChan2 := make(chan int, 100)
for i := 0; i < 100; i++ {
intChan2 <- i * 2 // 放入100个数据到管道
}
// 遍历
// 不能使用普通for循环,因为每取出一个数据长度都会减少,但形如while循环的
for len(intChan2) > 0 {
data := <-intChan2
fmt.Println(data)
}
// 是可以的
// 推荐使用for range,但要注意由于管道本身是一个队列,是没有下标的
// 记得关闭管道,才能进行遍历哦!!
close(intChan2)
for v := range intChan2 {
fmt.Println(v)
}
Channel与Goroutine协同工作
我们就来解决之前抛出的主线程先于协程完成任务而将协程销毁的问题:
func writeData(intChan chan int) {
for i := 0; i < 50; i++ {
// 放入数据
intChan <- i
fmt.Println("写入数据", i)
}
close(intChan)
}
func readData(intChan chan int, exitChan chan bool) {
for {
v, ok := <-intChan
if !ok { // 读完数据就退出
break
}
fmt.Println("读到数据", v)
}
// readData后,任务完成,则向exitChan写入true,使得主线程可以检测到并退出
close(exitChan)
}
func main() {
//创建两个管道
intChan := make(chan int, 50)
exitChan := make(chan bool, 1)
go writeData(intChan)
go readData(intChan, exitChan)
for { // 实时监测exitChan中是否有数据,有数据时主线程才能退出,由此使用Channel保证所有线程完成工作
_, ok := <-exitChan
if !ok {
break
}
}
}
实例:统计1-8000的数字中有哪些是素数
package main
import (
"fmt"
"time"
)
// 产生数字的函数
func putNum(intChan chan int) {
for i := 0; i < 8000; i++ {
intChan <- i
}
// 放入数字后,该函数不再工作,故可以关闭intChan
close(intChan)
}
// 干活和将结果存储起来的函数
func primeNum(intChan chan int, primeChan chan int, exitChan chan bool) {
var flag bool
for {
time.Sleep(time.Millisecond * 10) // 等待数字先全部生成
num, ok := <-intChan // 从生成数的管道中取数据
if !ok {
break
}
flag = true // 先假设是素数
// 判断num是否为素数
for i := 2; i < num; i++ {
if num%i == 0 {
flag = false
break
}
}
if flag {
// 将数放入到primeChan
primeChan <- num
}
}
fmt.Println("有一个prime协程已取不到数据,关闭了")
exitChan <- true // 向exitChan中写入一个标志
}
func main() {
intChan := make(chan int, 1000)
primeChan := make(chan int, 5000) // 放入结果的管道
exitChan := make(chan bool, 4) // 用于被检测与退出主线程的管道
// 开启一个协程,向intChan放入1-8000个数
go putNum(intChan)
// 开启4个协程,从intChan中取出数据,并判断是否为素数,如果是,就放入到primeChan中
for i := 0; i < 4; i++ {
go primeNum(intChan, primeChan, exitChan)
}
// 若从exitChan检测到4个标志,则主线程可以退出
go func() {
for i := 0; i < 4; i++ {
<-exitChan // 取不到4个标志就会等待
}
// 关闭工作管道
close(primeChan)
}()
// 遍历结果管道,取出结果
for {
res, ok := <-primeChan
if !ok {
break
}
fmt.Println("素数有", res)
}
}
大家有兴趣的话可以自己对比一下串行的解决此问题与使用channel与goroutine协同解决问题在程序效率上的不同,理论上由于goroutine可以更大限度的利用CPU,使其能并行工作,因此你的机器有几个CPU,大概后者就会比前者快几倍~
注意事项
1.channel可以声明为只写或者只读,默认情况下,管道是双向
var chan1 chan int // 默认双向,可读可写
var chan2 chan <-int // 只写
chan2 = make(chan int, 3) // 注意类型还是int
var chan3 <-chan int // 只读
可以应用到如exitChan中,防止误操作。
2.使用select可以解决从管道取数据的阻塞问题
func main() {
intChan := make(chan int, 4)
for i := 0; i < 10; i++ {
intChan <- i
}
stringChan := make(chan string, 5)
for i := 0; i < 5; i++ {
stringChan <- "hello" + fmt.Sprintf("%d", i)
}
// 传统的方法在遍历管道时,如果不关闭会阻塞而导致deadlock
// 如果在实际开发中,不好确定什么时候关闭,可使用select
for {
select {
case v := <- intChan: // 注意:这里如果intChan一直没有关闭,不会一直阻塞导致死锁,其会自动
//的向下一个case匹配
fmt.Println("从intChan中读取的数据", v)
case v := <- stringChan:
fmt.Println("从stringChan中读取的数据", v)
default:
fmt.Println("什么都取不到啦")
return // 退出主函数
}
}
}
3.防止一些协程出现了panic,为了代码的健壮性记得使用recover
func sayHello() {
for i := 0; i < 10; i++ {
time.Sleep(time.Second)
fmt.Println("Hello,World")
}
}
func test() {
defer func() {
if err := recover(); err != nil{
fmt.Println("test异常", err)
}
}()
var myMap map[int]string
myMap[0] = "golang" // 这里没有初始化会直接panic
}
go sayHello()
go test()
for i := 0; i < 10; i++ { // 匹配协程的运行时间
time.Sleep(time.Second)
}
反射
基本介绍
- 反射可以在运行时动态的获取变量的各种信息,比如变量的类型
- 如果是结构体变量,还可以获取到结构体本身的信息(包括结构体的字段、方法)
- 通过反射,可以修改变量的值,可以调用关联的方法
- 使用反射(reflect)是一个包哦
reflect.TypeOf(v) 拿到变量的数据类型,返回的是reflect.Type类型;
reflect.ValueOf(v) 拿到变量的值,返回的是一个结构体类型reflect.Value,它有很多方法可以获取变量的很多信息。
变量、interface{}、reflect.Value是可以相互转换的:
interface{}转成reflect.Value:
func test(b interface{}){
}
rVal := reflect.Value(b)
reflect.Value转成interface{}
iVal := rVal.interface()
interface{}转成原生变量类型
var Student Stu // 假设以及创建了一个变量名为Stu的结构体了哦
v := iVal.(Stu) // 类型断言
而变量通过传递参数则可以转为空接口。
比如:var num int = 100在以上三种类型间转换:
func reflectTest01(b interface{}) {
// 通过反射获取到传入的变量的type和kind与值
// 先获取reflect.Type
rType := reflect.TypeOf(b)
fmt.Println(rType) // 输出的是int,但实际上为reflect.Value,是不能和int一起操作的,用%T可以看到其真正的类型
// 获取reflect.Value
rVal := reflect.ValueOf(b)
n := 2 + rVal.Int() // 通过Int方法可以使其与int类型的变量运算
fmt.Println(n)
iV := rVal.Interface()
num2 := iV.(int) // 通过类型断言,又转为原生类型
fmt.Println(num2)
}
一个结构体的三种类型转换:
type Student struct {
Name string
Age int
}
// 声明变量
stu := Student{
Name: "Jack",
Age: 18,
}
func reflectTest02(b interface{}) {
// 通过反射获取到传入的变量的type和kind与值
// 先获取reflect.Type
rType := reflect.TypeOf(b)
fmt.Println(rType) // 输出的是int,但实际上为reflect.Value,是不能和int一起操作的,用%T可以看到其真正的类型
// 获取reflect.Value
rVal := reflect.ValueOf(b)
iV := rVal.Interface()
fmt.Println(iV)
stu, ok := iV.(Student)
if ok {
fmt.Println(stu.Name)
}
}
注意事项
1.reflect.Value.Kind,返回的是一个常量(const),Kind代表type类型值表示的具体分类,零值表示非法分类;
如main.Student是一个Kind,而struct是一个类型
2.Type是类型、Kind是类别,两种可能是相同的,也可能是不同的;
如:
var num int = 10 num的Type是int、Kind也是int
var stu Student stu的Type是包名.Student,Kind是struct
3.通过反射可以让变量在interface{}和Reflect.Value之间相互转换;
4.使用反射的方式来获取变量的值(并返回对应的类型),要求数据类型匹配,比如x是int,那么就应该使用reflect.Value(x).Int();
5.通过反射来修改变量,注意当使用SetXxx方法来设置的话,需要通过对应的指针类型来完成,这样才能改变传入的变量的值,同时需要使用到reflect.Value.Elem()方法;
Elem返回传入变量持有的接口保管的值的Value封装,或者其持有的指针指向的值的Value封装,如果其的Kind不是interface或Ptr会panic,如果持有的值为nil,会返回Value零值
就类似于:星号ptr中"星号"的作用
func reflect01(b interface{}) {
rVal := reflect.ValueOf(b)
// 查看rVal的Kind
fmt.Println(rVal.Kind()) // ptr,是一个指针
rVal.Elem().SetInt(20)
}
func main() {
var num int = 10
reflect01(&num)
fmt.Println(num)
}
应用实例:Json序列化通过反射获取结构体中的tag
这也是之前在学习结构体时所提到过的,应用很广泛,实现代码如下:
package main
import (
"fmt"
"reflect"
)
type Monster struct {
Name string `json:"name"`
Age int `json:"monster_age"`
Score float32
Sex string
}
func (s Monster) Print() {
fmt.Println(s)
}
func (s Monster) GetSum(n1, n2 int) int {
return n1 + n2
}
func (s Monster) Set(name string, age int, score float32, sex string) {
s.Name = name
s.Age = age
s.Score = score
s.Sex = sex
}
func TestStruct(a interface{}) {
// reflect.Type 类型
typ := reflect.TypeOf(a)
// reflect.Value 类型
val := reflect.ValueOf(a)
// 获取到a对应的类别
kd := val.Kind()
// 判断是否是个结构体
if kd != reflect.Struct {
fmt.Println("expect struct")
return
}
// 获取到结构体有几个字段
num := val.NumField()
fmt.Printf("struct has %d fields\n", num)
// 遍历结构体所有字段
for i := 0; i < num; i++ {
fmt.Printf("Field %d value=%v\n", i, val.Field(i))
// 获取到struct标签,注意需要通过reflect.type来获取tag的值
tagVal := typ.Field(i).Tag.Get("json")
// 判断字段是否有tag标签
if tagVal != "" {
fmt.Printf("Field %d: tag为%v\n", i, tagVal)
}
}
// 获取到结构体有多少个方法
numOfMethod := val.NumField()
fmt.Printf("struct has %d method\n", numOfMethod)
// 获取到结构体的第2个方法并调用,注意函数排序时按照函数名的ASCII码排序的,所以是Print
val.Method(1).Call(nil)
// 调用结构体第1个方法
var params []reflect.Value // 声明一个切片
// 传参
params = append(params, reflect.ValueOf(10))
params = append(params, reflect.ValueOf(40))
// 返回的还是一个切片
res := val.Method(0).Call(params)
fmt.Println("res=", res[0].Int()) // 最好还是用类型断言
}
func main() {
var a Monster = Monster{
Name: "哥斯拉",
Age: 18,
Score: 60.0,
}
TestStruct(a)
}
以上内容若有不正之处,恳请您不吝指正!