go基本语法

1.go的基础语法

1.1go语言的起源

Go语言最初由Google公司的Robert Griesemer、Ken Thompson和Rob Pike三个大牛于2007年开始设计发明,他们最终的目标是设计一种适应网络和多核时代的C语言。所以Go语言很多时候被描述为“类C语言”,或者是“21世纪的C语言”,当然从各种角度看,Go语言确实是从C语言继承了相似的表达式语法、控制流结构、基础数据类型、调用参数传值、指针等诸多编程思想。但是Go语言更是对C语言最彻底的一次扬弃,它舍弃了C语言中灵活但是危险的指针运算,还重新设计了C语言中部分不太合理运算符的优先级,并在很多细微的地方都做了必要的打磨和改变。可以说是一门不错的语言。

优点:

K8s、Docker、etcd 这类耳熟能详的工具,就是用 Go 语言开发的,而且很多大公司(如腾讯、字节跳动等)都在把原来 C/C++、Python、PHP 的技术栈迁往 Go 语言。

在我看来,Go 作为一门高效率的工业化语言备受推崇,这与其语言本身的优势有直接的关系:

  • 语法简洁,相比其他语言更容易上手,开发效率更高;
  • 自带垃圾回收(GC),不用再手动申请释放内存,能够有效避免 Bug,提高性能;
  • 语言层面的并发支持,让你很容易开发出高性能的程序;
  • 提供的标准库强大,第三方库也足够丰富,可以拿来即用,提高开发效率;
  • 可通过静态编译直接生成一个可执行文件,运行时不依赖其他库,部署方便,可伸缩能力强;
  • 提供跨平台支持,很容易编译出跨各个系统平台直接运行的程序。
  • Go 语言的整体设计理念就是以软件工程为目的的,也就是说它不是为了编程语言本身多么强大而设计,而是为了开发者更好地研发、管理软件工程,一切都是为了开发者着想。**

1.2go版本的hello,world

我们学习一门语言总是从下面这段代码开始

package main
import "fmt"
func main() {// 终端输出hello world
    fmt.Println("Hello world!")
}

和C语言相似,go语言的基本组成有:

  • 包声明,编写源文件时,必须在非注释的第一行指明这个文件属于哪个包,如package main
  • 引入包,其实就是告诉Go 编译器这个程序需要使用的包,如import "fmt"其实就是引入了fmt包。
  • 函数,和c语言相同,即是一个可以实现某一个功能的函数体,每一个可执行程序中必须拥有一个main函数。
  • 变量,Go 语言变量名由字母、数字、下划线组成,其中首个字符不能为数字。
  • 语句/表达式,在 Go 程序中,一行代表一个语句结束。每个语句不需要像 C 家族中的其它语言一样以分号 ; 结尾,因为这些工作都将由 Go 编译器自动完成。
  • 注释,和c语言中的注释方式相同,可以在任何地方使用以 // 开头的单行注释。以 /* 开头,并以 */ 结尾来进行多行注释,且不可以嵌套使用,多行注释一般用于包的文档描述或注释成块的代码片段。

需要注意的是:标识符是用来命名变量、类型等程序实体。一个标识符实际上就是一个或是多个字母和数字、下划线_组成的序列,但是第一个字符必须是字母或下划线而不能是数字。

  1. 当标识符(包括常量、变量、类型、函数名、结构字段等等)以一个大写字母开头,如:Group1,那么使用这种形式的标识符的对象就可以被外部包的代码所使用(客户端程序需要先导入这个包),这被称为导出(像面向对象语言中的 public);
  2. 标识符如果以小写字母开头,则对包外是不可见的,但是他们在整个包的内部是可见并且可用的(像面向对象语言中的 protected)。

就是一句话,Go的标识符严格区分大小写,以小写开头的是包外不可见,大写开头可以被外部的包使用

package main
import "fmt"
func main() {
//1.基础语法
	fmt.Println("世界,你好")
	//变量定义两种方式   var 变量名  类型      变量名 := 值
    //var 变量名 类型 = 表达式
    //可以一次定义多个值
    var (
    j int= 0
    k int= 1
)
  //需要注意的是,go具有类型推到的功能,可以省略类型
    var i = 10
    //第二种,变量简短声明,变量名:=表达式
   //二者的区别是 :=只能用在方法内,适合局部变量,用var可以做全局变量和局部变量
	var i1 int
	var i2 int
	i1 = 1
	i2 = 2
	fmt.Println(i2)
	fmt.Println(i1)
	var name string
	name = "我是世界上最棒的人"
	fmt.Println(name)
	//常量定义
	const k = 3
	fmt.Println(k)
    //多个常量定义,可以使用iota常量生成器
    const(
    one = iota+1   //后面自增1
    two
    three
    four

)
	i := 234
	fmt.Println(i)
	f := 3.14
	b := false
	s := "hello"
	fmt.Printf("i = %d, f = %f,b =%t,s = %s +\n", i, f, b, s)
	//结构体
	type Person struct {
		Name string
		Age  int
	}
	var p1 Person
	p1.Name = "张三"
	p1.Age = 20
	fmt.Println(p1)
}

类型转换

ii := 123
	itoa := strconv.Itoa(ii) //整形转字符串
	i1 := "123"
	atoi, err := strconv.Atoi(i1) //字符串转整型
	if err != nil {
	}
	fmt.Println(atoi)
	fmt.Println(itoa)

通过包 strconv 的 Itoa 函数可以把一个 int 类型转为 string,Atoi 函数则用来把 string 转为 int。

同理对于浮点数、布尔型,Go 语言提供了 strconv.ParseFloat、strconv.ParseBool、strconv.FormatFloat 和 strconv.FormatBool 进行互转,你可以自己试试。

字符串的包strings

//判断s1的前缀是否是H

fmt.Println(strings.HasPrefix(s1,"H"))

//在s1中查找字符串o

fmt.Println(strings.Index(s1,"o"))

//把s1全部转为大写

fmt.Println(strings.ToUpper(s1))

指针细说

在 Go 语言中,指针对应的是变量在内存中的存储位置,也就说指针的值就是变量的内存地址。通过 & 可以获取一个变量的地址,也就是指针。在以下的代码中,pi 就是指向变量 i 的指针。要想获得指针 pi 指向的变量值,通过*pi这个表达式即可。尝试运行这段程序,会看到输出结果和变量 i 的值一样。

i:=10
pi:=&i
fmt.Println(*pi)
//指针类型
	var x int = 10
    //声明一个整数的指针,此时是个零值指针,指向nil,
	var p *int
    //将指针p指向x,&x代表取地址 
	p = &x
    /* 简单来说 *代表解引用操作符,&代表取地址操作符
    &x 表示获取整数变量 x 的内存地址。
*p 表示访问指针变量 p 所指向的变量的值。
&、
*/
	fmt.Println(*p) //打印值
    fmt.PrintLn(p) //打印地址

go的常见运算符如下

1.算术运算符:
+:加法
-:减法
*:乘法
/:除法
%:取余

2.比较运算符:
==:等于
!=:不等于
<:小于
>:大于
<=:小于等于
>=:大于等于

3,逻辑运算符:
&&:与(逻辑与)
||:或(逻辑或)
!:非(逻辑非)

4.位运算符:
&:按位与
|:按位或
^:按位异或
<<:左移位
>>:右移位
&^:位清除(AND NOT)

5.赋值运算符:
=:赋值
+=:加法赋值
-=:减法赋值
*=:乘法赋值
/=:除法赋值
%=:取余赋值

6.其它运算符:
&:取地址
*:指针解引用
++:自增
--:自减
<-:通道操作符(用于发送和接收数据)

1.3 数据类型

类型详解
布尔型布尔型的值只可以是常量 true 或者 false。
数字类型整型 int 和浮点型 float。Go 语言支持整型和浮点型数字,并且支持复数,其中位的运算采用补码,分类有符号位和无符号位,
字符串类型字符串就是一串固定长度的字符连接起来的字符序列。Go 的字符串是由单个字节连接起来的。Go 语言的字符串的字节使用 UTF-8 编码标识 Unicode 文本。
派生类型(a) 指针类型(Pointer)(b) 数组类型© 结构体类型(struct)(d) Channel 类型(e) 函数类型(f) 切片类型(g) 接口类型(interface)(h) Map 类型

对于派生类型,我们对指针,结构体,接口,Map类型单独说明

零值:

var zi int  //0
var zf float64 //0
var zb bool   //false
var zs string  // ""
fmt.Println(zi,zf,zb,zs)

1.3.1指针

指针是一种变量,用于存储其他变量的内存地址。在Go中,指针类型表示为 *T,其中 T 是指向的数据类型。指针用于引用和操作数据的内存地址,它允许在函数之间共享数据的引用,可以减少数据的拷贝,提高程序的性能。指针的零值是 nil,表示不指向任何有效的内存地址。

var x int = 10
var p *int // 声明一个整数类型的指针
p = &x     // 将指针p指向变量x的内存地址
fmt.Println(*p) // 输出:10,通过指针访问变量x的值

1.3.2 结构体类型

结构体是一种用户自定义的复合数据类型,它允许将不同类型的数据组合在一起,创建更复杂的数据结构。结构体由一组字段(字段名和字段类型)组成,每个字段可以是不同类型的数据。结构体通常用于表示实体对象或数据记录。

type Person struct {
    Name string
    Age  int
}
var p Person
p.Name = "Alice"
p.Age = 30

1.3.3 接口类型

接口是一种抽象数据类型,用于定义对象的行为。接口描述了一组方法的签名,任何实现这些方法的类型都可以被认为实现了该接口。接口提供了多态性的概念,允许不同类型的对象以一致的方式进行处理。

package main
import "fmt"
type Shape interface {
    Area() float64
}
type Circle struct {
    Radius float64
}
func (c Circle) Area() float64 {
    return 3.14 * c.Radius * c.Radius
}

func main() {
    // 创建一个 Circle 结构体实例
    circle := Circle{Radius: 5.0}

    // 通过接口调用 Area 方法
    var shape Shape
    shape = circle // 将 Circle 实例赋值给接口,可以理解为circle是接口的实现类
    area := shape.Area()

    fmt.Printf("圆的面积是:%f\n", area)
}

1.3.4 Map(映射)类型

映射是一种无序的键-值对数据结构,也被称为字典。在Go中,映射是一种内置的数据类型,用于将键与值关联起来。映射通常用于查找表、关联数据以及用于快速查找和检索数据。

phonebook := make(map[string]string) // 创建一个字符串到字符串的映射
phonebook["Alice"] = "123-456-789"
phone := phonebook["Alice"] // 查找键对应的值
fmt.Println(phoneName)   // 打印值
fmt.Println(phone)      

1.4分支循环

1.4.1 if

  1. if 后面的条件表达式不需要使用 (),这和有些编程语言不一样,也更体现 Go 语言的简洁;
  2. 每个条件分支(if 或者 else)中的大括号是必须的,哪怕大括号里只有一行代码(如示例);
  3. if 紧跟的大括号 { 不能独占一行,else 前的大括号 } 也不能独占一行,否则会编译不通过;
  4. 在 if……else 条件语句中还可以增加多个 else if,增加更多的条件分支。
package main

import "fmt"

func main() {
    x := 10
    if x > 5 {
        fmt.Println("x 大于 5")
    } else {
        fmt.Println("x 小于等于 5")
    }
    
       i:=6
    if i >10 {
        fmt.Println("i>10")
    } else if  i>5 && i<=10 {
        fmt.Println("5<i<=10")
    } else {
        fmt.Println("i<=5")
    }
}

1.4.2 switch

if 条件语句比较适合分支较少的情况,如果有很多分支的话,选择 switch 会更方便,比如以上示例,使用 switch 改造后的代码如下:

package main

import "fmt"

func main() {
    day := "Wednesday"
    switch day {
    case "Monday":
        fmt.Println("星期一")
    case "Wednesday":
        fmt.Println("星期三")
    default:
        fmt.Println("其他天")
    }
    
}

注意这里不需要break,

1.4.3 for

for循环用于重复执行代码块,有两种常见的方式:

基本for循环

package main

import "fmt"

func main() {
    for i := 1; i <= 5; i++ {
        fmt.Println(i)
    }
}

  1. 第一部分是一个简单语句,一般用于 for 循环的初始化,比如这里声明了一个变量,并对 i:=1 初始化;
  2. 第二部分是 for 循环的条件,也就是说,它表示 for 循环什么时候结束。这里的条件是 i<=100;
  3. 第三部分是更新语句,一般用于更新循环的变量,比如这里 i++,这样才能达到递增循环的目的。

需要特别留意的是,Go 语言里的 for 循环非常强大,以上介绍的三部分组成都不是必须的,可以被省略,下面我就来为你演示,省略以上三部分后的效果。

sum:=0
i:=1
for i<=100 {
    sum+=i
    i++
}

fmt.Println("the sum is",sum)

这个示例和上面的 for 示例的效果是一样的,但是这里的 for 后只有 i<=100 这一个条件语句,也就是说,它达到了 while 的效果。

在 Go 语言中,同样支持使用 continue、break 控制 for 循环:

  1. continue 可以跳出本次循环,继续执行下一个循环。
  2. break 可以跳出整个 for 循环,哪怕 for 循环没有执行完,也会强制终止。

for range 循环

package main

import "fmt"

func main() {
    nums := []int{1, 2, 3, 4, 5}
    for index, value := range nums {
        fmt.Printf("索引 %d 的值是 %d\n", index, value)  //推荐使用
    }
}

1.4.4 while

go这个语言中没有专门的while循环,所以我们需要使用到无限循环的时候,可以使用for循环来模拟

package main
import "fmt"
func main() {
    count := 0
    for count < 5 {
        fmt.Println(count)
        count++
    }
}

1.5数组和切片以及映射操作

数组是一种固定长度的数据结构,其中每个元素都具有相同的数据类型。在Go中,数组的长度是数组类型的一部分,因此不同长度的数组被认为是不同的类型。数组的每个元素都是连续存放的,每一个元素都有一个下标(Index)。下标从 0 开始,比如第一个元素 a 对应的下标是 0,第二个元素 b 对应的下标是 1。以此类推,通过 array+[下标] 的方式,我们可以快速地定位元素。

1.5.1 数组定义

var arr [5]int                // 声明一个包含5个整数的数组,元素初始化为零值
arr := [5]int{1, 2, 3, 4, 5}   // 使用初始化值声明并初始化数组
arr := [...]int{1, 2, 3}       // 根据初始化值自动确定数组长度
array :=[5]string{1:"b",3:"d"} // 初始化指定索引的数据

访问数组元素

arr[0] = 10      // 设置第一个元素的值为10
value := arr[2] // 获取第三个元素的值

获取数组长度

length := len(arr) // 获取数组长度,结果为5

数组的遍历

可以使用for循环遍历数组元素。

   //2.数组操作,数组定长
   var arr [3]int
   arr[0] = 1
   arr[1] = 2
   arr[2] = 3
   //切片操作,切片不定长
   brr := []int{1, 2, 4}
   fmt.Println(arr[0], brr[0])
   //循环遍历数组
   for i := 0; i < len(arr); i++ {
      fmt.Println(arr[i])
   }
   //循环遍历切片,_代表不接收参数的意思
   for _, v := range brr {
      fmt.Println(v)
   }

二维数组定义

aa:=[3][3]int{}
aa[0][0] =1
aa[0][1] =2
aa[0][2] =3
aa[1][0] =4
aa[1][1] =5
aa[1][2] =6
aa[2][0] =7
aa[2][1] =8
aa[2][2] =9
fmt.Println(aa)
//比较合适的下面
bb := [3][3]int{
		{1,2,3},
		{4,5,6},
		{7,8,9},
	}

1.5.2 切片定义

切片和数组类似,可以把它理解为动态数组。切片是基于数组实现的,它的底层就是一个数组。对数组任意分隔,就可以得到一个切片。现在我们通过一个例子来更好地理解它,同样还是基于上述例子的 array。

var slice []int                  // 声明一个整数切片,未分配底层数组
slice := []int{1, 2, 3, 4, 5}    // 使用初始化值创建切片
slice := make([]int, 5)          // 使用make函数创建长度为5的切片,元素初始化为零值
slice := make([]int, 5, 10)      // 创建长度为5、容量为10的切片

基础操作如下

添加元素:使用append函数向切片中添加元素。
slice = append(slice, 6)      // 添加元素6到切片
//追加一个元素
slice2:=append(slice1,"f")
//多加多个元素
slice2:=append(slice1,"f","g")
//追加另一个切片
slice2:=append(slice1,slice...)

切片:使用切片表达式来获取部分切片。
newSlice := slice[1:4]        // 获取索引1到3的切片,不包括索引4

修改元素:通过切片修改底层数组的元素也会影响原始切片和其他切片。
slice[0] = 10                 // 修改第一个元素的值为10
删除元素:Go语言没有直接的删除切片元素的内置函数。可以通过创建新的切片来实现删除元素。
// 删除索引为2的元素
slice = append(slice[:2], slice[3:]...)
获取切片长度和容量
length := len(slice)             // 获取切片长度
capacity := cap(slice)           // 获取切片容量
切片的遍历
可以使用for循环遍历切片元素,与数组类似。
for index, value := range slice {
    // 使用 index 和 value 执行代码
}

基于数组生成切片

//基于数组生成切片,包含索引start,但是不包含索引end
slice:=array[start:end]

这里有一些小技巧,切片表达式 array[start:end] 中的 start 和 end 索引都是可以省略的,如果省略 start,那么 start 的值默认为 0,如果省略 end,那么 end 的默认值为数组的长度。如下面的示例:

  1. array[:4] 等价于 array[0:4]。
  2. array[1:] 等价于 array[1:5]。
  3. array[:] 等价于 array[0:5]。

1.5.3映射(Map)

在 Go 语言中,map 是一个无序的 K-V 键值对集合,结构为 map[K]V。其中 K 对应 Key,V 对应 Value。map 中所有的 Key 必须具有相同的类型,Value 也同样,但 Key 和 Value 的类型可以不同。此外,Key 的类型必须支持 == 比较运算符,这样才可以判断它是否存在,并保证 Key 的唯一。

1.map声明初始化

//1.方式1,使用make函数
nameAgeMap:=make(map[string]int)  //key为string类型,value为int类型
//2.方式2.使用字面量
nameAgeMap:=map[string]int{"飞雪无情":20}

它的 Key 类型为 string,Value 类型为 int。有了创建好的 map 变量,就可以对它进行操作了。在下面的示例中,我添加了一个键值对,Key 为飞雪无情,Value 为 20,如果 Key 已经存在,则更新 Key 对应的 Value:

2.map获取和删除

//添加键值对或者更新对应 Key 的 Value
nameAgeMap["飞雪无情"] = 20
//获取指定 Key 对应的 Value
age:=nameAgeMap["飞雪无情"]
//delete 有两个参数:第一个参数是 map,第二个参数是要删除键值对的 Key。
delete(nameAgeMap,"飞雪无情")
//map只有大小,就是键值对的数量
fmt.Println(len(nameAgeMap))

需要注意的是

Go 语言的 map 可以获取不存在的 K-V 键值对,如果 Key 不存在,返回的 Value 是该类型的零值,比如 int 的零值就是 0。所以很多时候,我们需要先判断 map 中的 Key 是否存在。
map 的 [] 操作符可以返回两个值:
第一个值是对应的 Value;
第二个值标记该 Key 是否存在,如果存在,它的值为 true。

nameAgeMap:=make(map[string]int)
nameAgeMap["飞雪无情"] = 20
age,ok:=nameAgeMap["飞雪无情1"]
if ok {
    fmt.Println(age)
}

遍历map

nameAgeMap["飞雪无情"] = 20
nameAgeMap["飞雪无情1"] = 21
nameAgeMap["飞雪无情2"] = 22
for k,v:=range nameAgeMap{
    fmt.Println("Key is",k,",Value is",v)
}

需要注意的是 map 的遍历是无序的,也就是说你每次遍历,键值对的顺序可能会不一样。如果想按顺序遍历,可以先获取所有的 Key,并对 Key 排序,然后根据排序好的 Key 获取对应的 Value。这里我不再进行演示,你可以当作练习题。

小技巧:for range map 的时候,也可以使用一个值返回。使用一个返回值的时候,这个返回值默认是 map 的 Key。

string和【】byte

字符串 string 也是一个不可变的字节序列,所以可以直接转为字节切片 []byte,如下面的代码所示:

package main
import "fmt"
func main() {
	s := "Hello飞雪无情"
	bs := []byte(s)
	fmt.Println(bs)
	fmt.Println(s[0], s[1], s[15])
    fmt.Println(utf8.RuneCountInString(s))
	for i, r := range s {
		fmt.Println(i, r)
	}
}
//执行结果如下
[72 101 108 108 111 233 163 158 233 155 170 230 151 160 230 131 133]
72 101 131
9         
0 72      
1 101     
2 108     
3 108     
4 111     
5 39134   
8 38634   
11 26080  
14 24773  

因为字符串是字节序列,每一个索引对应的是一个字节,而在 UTF8 编码下,一个汉字对应三个字节,所以字符串 s 的长度其实是 17。而使用 for range 对字符串进行循环时,也恰好是按照 unicode 字符进行循环的,所以对于字符串 s 来说,循环了 9 次。在下面示例的代码中,i 是索引,r 是 unicode 字符对应的 unicode 码点,这也说明了 for range 循环在处理字符串的时候,会自动地隐式解码 unicode 字符串。

1.6函数和方法操作

函数和方法是我们迈向代码复用、多人协作开发的第一步。通过函数,可以把开发任务分解成一个个小的单元,这些小单元可以被其他单元复用,进而提高开发效率、降低代码重合度。再加上现成的函数已经被充分测试和使用过,所以其他函数在使用这个函数时也更安全,比你自己重新写一个相似功能的函数 Bug 率更低。这节课,我会详细讲解 Go 语言的函数和方法,了解它们的声明、使用和不同。虽然在 Go 语言中有函数和方法两种概念,但它们的相似度非常高,只是所属的对象不同。我们先从函数开始了解。

1.6.1函数

func main() {
    
}
  1. 任何一个函数的定义,都有一个 func 关键字,用于声明一个函数,就像使用 var 关键字声明一个变量一样;
  2. 然后紧跟的 main 是函数的名字,命名符合 Go 语言的规范即可,比如不能以数字开头;
  3. main 函数名字后面的一对括号 () 是不能省略的,括号里可以定义函数使用的参数,这里的 main 函数没有参数,所以是空括号 () ;
  4. 括号 () 后还可以有函数的返回值,因为 main 函数没有返回值,所以这里没有定义;
  5. 最后就是大括号 {} 函数体了,你可以在函数体里书写代码,写该函数自己的业务逻辑。

函数的格式体如下

func funcName(params) result {  //函数定义格式
    body
}

这就是一个函数的签名定义,它包含以下几个部分:

  1. 关键字 func;
  2. 函数名字 funcName;
  3. 函数的参数 params,用来定义形参的变量名和类型,可以有一个参数,也可以有多个,也可以没有;
  4. result 是返回的函数值,用于定义返回值的类型,如果没有返回值,省略即可,也可以有多个返回值;
  5. body 就是函数体,可以在这里写函数的代码逻辑。

需要注意的点,

1.函数可以返回多个值

同有的编程语言不一样,Go 语言的函数可以返回多个值,也就是多值返回。在 Go 语言的标准库中,你可以看到很多这样的函数:第一个值返回函数的结果,第二个值返回函数出错的信息,这种就是多值返回的经典应用。

func sum(a, b int) (int,error){
    if a<0 || b<0 {
        return 0,errors.New("a或者b不能是负数")
    }
    return a + b,nil
}

2.有的返回值不需要,可以用_去掉

3.返回参数也可以命名

func sum(a, b int) (sum int,err error){} //类似于这样

4.可变参数

就是函数的参数数量是可变的,比如最常见的 fmt.Println 函数。同样一个函数,可以不传参数,也可以传递一个参数,也可以两个参数,也可以是多个等等,这种函数就是具有可变参数的函数,如下所示:

fmt.Println()
fmt.Println("飞雪")
fmt.Println("飞雪","无情")
func Println(a ...interface{}) (n int, err error) //定义格式
func sum1(params ...int) int {   //改编成可变参数
    sum := 0
    for _, i := range params {
        sum += i
    }
    return sum
}
fmt.Println(sum1(1,2))
fmt.Println(sum1(1,2,3))
fmt.Println(sum1(1,2,3,4))
/*这里需要注意,如果你定义的函数中既有普通参数,又有可变参数,那么可变参数一定要放在参数列表的最后一个,比如 sum1(tip string,params …int) ,params 可变参数一定要放在最末尾。*/

5.包级函数

不管是自定义的函数 sum、sum1,还是我们使用到的函数 Println,都会从属于一个包,也就是 package。sum 函数属于 main 包,Println 函数属于 fmt 包。同一个包中的函数哪怕是私有的(函数名称首字母小写)也可以被调用。如果不同包的函数要被调用,那么函数的作用域必须是公有的,也就是函数名称的首字母要大写,比如 Println。在后面的包、作用域和模块化的课程中我会详细讲解,这里可以先记住:

  1. 函数名称首字母小写代表私有函数,只有在同一个包中才可以被调用;
  2. 函数名称首字母大写代表公有函数,不同的包也可以调用;
  3. 任何一个函数都会从属于一个包。

小提示:Go 语言没有用 public、private 这样的修饰符来修饰函数是公有还是私有,而是通过函数名称的大小写来代表,这样省略了烦琐的修饰符,更简洁。

6.匿名函数和闭包

func main() {
    sum2 := func(a, b int) int {
        return a + b
    }
    fmt.Println(sum2(1, 2))
}

通过 sum2,我们可以对匿名函数进行调用,以上示例算出的结果是 3,和使用正常的函数一样。有了匿名函数,就可以在函数中再定义函数(函数嵌套),定义的这个匿名函数,也可以称为内部函数。更重要的是,在函数内定义的内部函数,可以使用外部函数的变量等,这种方式也称为闭包。

func main() {
    cl:=colsure()
    fmt.Println(cl())
    fmt.Println(cl())
    fmt.Println(cl())
}
func colsure() func() int {
    i:=0
    return func() int {
        i++
        return i
    }
}

1.6.2方法

不同于函数的方法,在 Go 语言中,方法和函数是两个概念,但又非常相似,不同点在于方法必须要有一个接收者,这个接收者是一个类型,这样方法就和这个类型绑定在一起,称为这个类型的方法。

例如:

type Age uint
func (age Age) String(){
    fmt.Println("the age is",age)
}

方法 String() 就是类型 Age 的方法,类型 Age 是方法 String() 的接收者。和函数不同,定义方法时会在关键字 func 和方法名 String 之间加一个接收者 (age Age) ,接收者使用小括号包围。接收者的定义和普通变量、函数参数等一样,前面是变量名,后面是接收者类型。现在方法 String() 就和类型 Age 绑定在一起了,String() 是类型 Age 的方法。

方法的格式如下:

func (接收者变量名 接收者类型) 方法名()
func 方法名(参数) 返回值 {  //函数定义格式
    执行结果
}

接收者就是函数和方法的最大不同,此外,上面所讲到的函数具备的能力,方法也都具备。

提示:因为 25 也是 unit 类型,unit 类型等价于我定义的 Age 类型,所以 25 可以强制转换为 Age 类型。

在 Go 语言中,虽然存在函数和方法两个概念,但是它们基本相同,不同的是所属的对象。函数属于一个包,方法属于一个类型,所以方法也可以简单地理解为和一个类型关联的函数。不管是函数还是方法,它们都是代码复用的第一步,也是代码职责分离的基础。掌握好函数和方法,可以让你写出职责清晰、任务明确、可复用的代码,提高开发效率、降低 Bug 率

package main
import "fmt"
func main() {
   //函数操作
   print("我是函数调用" + "\n")
   c := add(1, 2)
   print(c)
   x, y := pow(2, 3)
   fmt.Println(x, y)
   d := apply(1, 2, add)
   fmt.Println(d)
}
// 函数调用返回单个值
func add(a int, b int) int {
   return a + b
}
// 函数调用返回多个值
func pow(a int, b int) (int, int) {
   a = a * a
   b = b * b
   return a, b
}
// 函数作为参数传递
func apply(a int, b int, f func(int, int) int) int {
   return f(a, b)
}

需要注意的点:

go的函数可以返回多个值,并且可以把函数作为参数传递。

切片和数组的区别:简单地说,切片就是一种简化版的动态数组。因为动态数组的长度不固定,切片的长度自然也就不能是类型的组成部分了。数组虽然有适用它们的地方,但是数组的类型和操作都不够灵活,而切片则使用得相当广泛。切片高效操作的要点是要降低内存分配的次数,尽量保证append操作(在后续的插入和删除操作中都涉及到这个函数)不会超出cap的容量,降低触发内存分配的次数和每次分配内存大小。

1.7 goroutine

goroutine(协程)是Go语言中的一种并发执行的轻量级线程。它是Go语言并发模型的核心组成部分,与传统的操作系统线程或进程相比,Goroutine更加轻量级、高效,且易于管理。

它有两个显著的特点

1. 7.1 轻量级

Goroutine相对于传统线程非常轻量级,可以在一个应用程序中创建数千甚至数百万个Goroutine而不会导致系统资源的枯竭。这是因为Goroutine共享相同的线程池(通常是操作系统的线程池),由Go运行时系统自动调度,而不是每个Goroutine都有自己的操作系统线程。

1.7.2. 并发执行

Goroutine允许程序同时执行多个任务,而不需要显式创建和管理线程。这使得编写并发程序变得更加容易,因为您可以将任务分解为多个Goroutine,它们可以并行执行。

1.7.3 Go关键字

要创建一个Goroutine,只需使用go关键字后跟一个函数调用,就可以在新的Goroutine中执行该函数。例如:

go func() {
    // 这部分代码在新的Goroutine中执行,相当于创建了一个子线程
}()
//也可以是下面的格式
go function() 

示例代码:

func main() {
   go fmt.Println("月弥")
   fmt.Println("我是 main goroutine")
   time.Sleep(time.Second)
}

小提示:示例中的 time.Sleep(time.Second) 表示等待一秒,这里是让 main goroutine 等一秒,不然 main goroutine 执行完毕程序就退出了,也就看不到启动的新 goroutine 中“飞雪无情”的打印结果了。

创建一个线程的代码如下

package main

import (
	"fmt"
	"time"
)

func main() {
	// 启动一个新的Goroutine 子线程
	go func() {
		for i := 0; i < 5; i++ {
			fmt.Println("Goroutine:", i)
		}
	}()

	// 主Goroutine执行的代码 主线程
	for i := 0; i < 3; i++ {
		fmt.Println("Main Goroutine:", i)
	}

	// 等待一段时间,以便Goroutine有足够的时间执行
	time.Sleep(time.Second)
}

注意上面的代码手动休眠的做法不是最佳选择,可以使用对应的同步机制,代码如下

package main

import (
	"fmt"
	"sync"
)

func main() {	
var wg sync.WaitGroup // 创建一个等待组

	// 增加等待组计数,表示有一个Goroutine正在执行
	wg.Add(1)

	// 启动一个新的Goroutine
	go func() {
		defer wg.Done() // 减少等待组计数,表示Goroutine执行完毕
		for i := 0; i < 100; i++ {
			fmt.Println("子线程:", i)
		}
	}()

	// 主Goroutine执行的代码
	for i := 0; i < 200; i++ {
		fmt.Println("Main 主线程:", i)
	}

	// 等待所有Goroutines完成
	wg.Wait() // 等待等待组中的计数归零,表示所有Goroutines都已完成

	fmt.Println("所有线程执行完毕")
    
}

1.7.4 自动调度

Go运行时系统会自动管理Goroutine的调度,包括将Goroutines分配给可用的线程,管理线程池,以及在Goroutine阻塞时将线程切换到其他Goroutines。这样,程序员无需手动管理线程的创建和销毁,也无需担心死锁等并发问题。

1.7.5 通信通过通道

Goroutines之间的通信通常通过通道(Channel)进行。通道提供了一种同步的方式,用于在Goroutines之间传递数据和进行通信。通过通道,Goroutines可以安全地共享数据,避免竞态条件和数据竞争

1.8 Channel

Go 语言之所以开始流行起来,很大一部分原因是因为它自带的并发机制。如果说 goroutine 是 Go语言程序的并发体的话,那么 channel(信道) 就是 它们之间的通信机制。channel,是一个可以让一个 goroutine 与另一个 goroutine 传输信息的通道,我把他叫做信道,也有人将其翻译成通道,二者都是一个概念。信道,就是一个管道,连接多个goroutine程序 ,它是一种队列式的数据结构,遵循先入先出的规则。

Channel(通道)是Go语言中用于在不同goroutine之间传递数据和进行通信的重要机制。通道提供了一种同步的方式,确保数据的安全传递,并且可以帮助协调不同goroutine的执行。

1.8.1 创建通道

可以使用make函数创建一个通道,指定通道中元素的类型:

信道实例 := make(chan 信道类型)
ch := make(chan int) // 创建一个整数类型的通道

1.8.2 发送和接收数据

  1. 接收:获取 chan 中的值,操作符为 <- chan。
  2. 发送:向 chan 发送值,把值放在 chan 中,操作符为 chan <-。

小技巧:这里注意发送和接收的操作符,都是 <- ,只不过位置不同。接收的 <- 操作符在 chan 的左侧,发送的 <- 操作符在 chan 的右侧。

ch <- 42 // 将整数42发送到通道
value := <-ch // 从通道中接收一个整数并将其赋给变量value
close(ch) //关闭信道
x, ok := <-ch //当从信道中读取数据时,可以有多个返回值,其中第二个可以表示 信道是否被关闭,如果已经被关闭,ok 为 false,若还没被关闭,ok 为true。

1.8.3信道的长度与容量

一般创建信道都是使用 make 函数,make 函数接收两个参数

第一个参数:必填,指定信道类型

第二个参数:选填,不填默认为0,指定信道的容量(可缓存多少数据)

对于信道的容量,很重要,这里要多说几点:

当容量为0时,说明信道中不能存放数据,在发送数据时,必须要求立马有人接收,否则会报错。此时的信道称之为无缓冲信道

当容量为1时,说明信道只能缓存一个数据,若信道中已有一个数据,此时再往里发送数据,会造成程序阻塞。 利用这点可以利用信道来做锁。

一个有缓冲 channel 具备以下特点:

  1. 有缓冲 channel 的内部有一个缓冲队列;
  2. 发送操作是向队列的尾部插入元素,如果队列已满,则阻塞等待,直到另一个 goroutine 执行,接收操作释放队列的空间;
  3. 接收操作是从队列的头部获取元素并把它从队列中删除,如果队列为空,则阻塞等待,直到另一个 goroutine 执行,发送操作插入新的元素。

当容量大于1时,信道中可以存放多个数据,可以用于多个协程之间的通信管道,共享资源。

至此我们知道,信道就是一个容器。若将它比做一个纸箱子

它可以装10本书,代表其容量为10,当前只装了1本书,代表其当前长度为1

信道的容量,可以使用 cap 函数获取 ,而信道的长度,可以使用 len 长度获取。

package main

import "fmt"

func main() {
    pipline := make(chan int, 10)
    fmt.Printf("信道可缓冲 %d 个数据\n", cap(pipline))
    pipline<- 1
    fmt.Printf("信道中当前有 %d 个数据", len(pipline))
}

输出如下

信道可缓冲 10 个数据
信道中当前有 1 个数据

1.8.4信道的类型

缓冲信道

允许信道里存储一个或多个数据,这意味着,设置了缓冲区后,发送端和接收端可以处于异步的状态。

pipline := make(chan int, 10)

无缓冲信道

在信道里无法存储数据,这意味着,接收端必须先于发送端准备好,以确保你发送完数据后,有人立马接收数据,否则发送端就会造成阻塞,原因很简单,信道中无法存储数据。也就是说发送端和接收端是同步运行的。

pipline := make(chan int)

// 或者
pipline := make(chan int, 0)

如果一个 channel 被关闭了,就不能向里面发送数据了,如果发送的话,会引起 painc 异常。但是还可以接收 channel 里的数据,如果 channel 里没有数据的话,接收的数据是元素类型的零值。

双向信道与单向信道

通常情况下,我们定义的信道都是双向通道,可发送数据,也可以接收数据。

但有时候,我们希望对信道的数据流向做一些控制,比如这个信道只能接收数据或者这个信道只能发送数据。

因此,就有了 双向信道单向信道 两种分类。

双向信道

默认情况下你定义的信道都是双向的,比如下面代码

import (
    "fmt"
    "time"
)

func main() {
    pipline := make(chan int)

    go func() {
        fmt.Println("准备发送数据: 100")
        pipline <- 100
    }()

    go func() {
        num := <-pipline
        fmt.Printf("接收到的数据是: %d", num)
    }()
    // 主函数sleep,使得上面两个goroutine有机会执行
    time.Sleep(1)
}

单向信道

单向信道,可以细分为 只读信道只写信道

定义只读信道

var pipline = make(chan int)
type Receiver = <-chan int 
var receiver Receiver = pipline

定义只写信道

var pipline = make(chan int)
type Sender = chan<- int  
var sender Sender = pipline

可以简化

onlySend := make(chan<- int)  //定义只写信道
onlyReceive:=make(<-chan int) //定义只读信道

仔细观察,区别在于 <- 符号在关键字 chan 的左边还是右边。

<-chan 表示这个信道,只能从里发出数据,对于程序来说就是只读

chan<- 表示这个信道,只能从外面接收数据,对于程序来说就是只写

需要注意的点

信道本身就是为了传输数据而存在的,如果只有接收者或者只有发送者,那信道就变成了只入不出或者只出不入了吗,没什么用。所以只读信道和只写信道,唇亡齿寒,缺一不可。

import (
    "fmt"
    "time"
)
 //定义只写信道类型
type Sender = chan<- int  
//定义只读信道类型
type Receiver = <-chan int 
func main() {
    var pipline = make(chan int)
    go func() {
        var sender Sender = pipline
        fmt.Println("准备发送数据: 100")
        sender <- 100
    }()
    go func() {
        var receiver Receiver = pipline
        num := <-receiver
        fmt.Printf("接收到的数据是: %d", num)
    }()
    // 主函数sleep,使得上面两个goroutine有机会执行
    time.Sleep(1)
}
//作为参数传递
func counter(out chan<- int) {
  //函数内容使用变量out,只能进行发送操作
}

上面的代码可以改装之后如下

func main() {
	sender := make(chan<- int)
	receiver := make(<-chan int)
	ch := make(chan int)
	sender = ch
	receiver = ch
	go func() {
		fmt.Println("准备发送数据: 100")
		sender <- 100
	}()
	go func() {
		num := <-receiver
		fmt.Printf("接收到的数据是: %d", num)
	}()
	// 主函数sleep,使得上面两个goroutine有机会执行
	time.Sleep(time.Second)
}

1.8.4 遍历信道

遍历信道,可以使用 for 搭配 range关键字,在range时,要确保信道是处于关闭状态,否则循环会阻塞。

func fibonacci(mychan chan int) {
    n := cap(mychan)
    x, y := 1, 1
    for i := 0; i < n; i++ {
        mychan <- x
        x, y = y, x+y
    }
    // 记得 close 信道
    // 不然主函数中遍历完并不会结束,而是会阻塞。
    close(mychan)
}

func main() {
    pipline := make(chan int, 10)

    go fibonacci(pipline)
    // 取值
    for k := range pipline {
        fmt.Println(k)
    }
}

1.8.5示例代码

package main

import (
	"fmt"
	"sync"
)
func main() {
ch := make(chan int)
var wg sync.WaitGroup

// 增加等待组计数,表示有两个Goroutines需要等待
wg.Add(2)
// 启动一个Goroutine向通道发送数据
go func() {
   defer wg.Done()
   for i := 1; i <= 5; i++ {
      ch <- i
   }
   close(ch)
}()
// 启动另一个Goroutine从通道接收数据
go func() {
   defer wg.Done()
   for num := range ch {
      fmt.Printf("接收到数据:%d\n", num)
   }
}()
// 等待所有Goroutines完成
wg.Wait()
fmt.Println("所有Goroutines完成")
}

select+channel示例

假设要从网上下载一个文件,我启动了 3 个 goroutine 进行下载,并把结果发送到 3 个 channel 中。其中,哪个先下载好,就会使用哪个 channel 的结果。在这种情况下,如果我们尝试获取第一个 channel 的结果,程序就会被阻塞,无法获取剩下两个 channel 的结果,也无法判断哪个先下载好。这个时候就需要用到多路复用操作了,在 Go 语言中,通过 select 语句可以实现多路复用,其语句格式如下:

func main() {
	firstCh := make(chan string)
	secondCh := make(chan string)
	threeCh := make(chan string)
	go func() {
		firstCh <- downloadFile("firstCh")
	}()
	go func() {
		secondCh <- downloadFile("secondCh")
	}()
	go func() {
		threeCh <- downloadFile("threeCh")
	}()
	// 使用计数器来跟踪已接收的数据量
	receivedCount := 0
	for receivedCount < 3 {
		select {
		case filePath := <-firstCh:
			fmt.Println(filePath)
			receivedCount++
		case filePath := <-secondCh:
			fmt.Println(filePath)
			receivedCount++
		case filePath := <-threeCh:
			fmt.Println(filePath)
			receivedCount++
		}
	}
}
func downloadFile(chanName string) string {
	time.Sleep(time.Second * 3)
	return chanName + "filePath"
}

整体结构和 switch 非常像,都有 case 和 default,只不过 select 的 case 是一个个可以操作的 channel。

小提示:多路复用可以简单地理解为,N 个 channel 中,任意一个 channel 有数据产生,select 都可以监听到,然后执行相应的分支,接收数据并处理。

如果这些 case 中有一个可以执行,select 语句会选择该 case 执行,如果同时有多个 case 可以被执行,则随机选择一个,这样每个 case 都有平等的被执行的机会。如果一个 select 没有任何 case,那么它会一直等待下去。

channel 为什么是并发安全的呢?是因为 channel 内部使用了互斥锁来保证并发的安全,在 Go 语言中,不仅有 channel 这类比较易用且高级的同步机制,还有 sync.Mutex、sync.WaitGroup 等比较原始的同步机制。通过它们,我们可以更加灵活地控制数据的同步和多协程的并发,下

1.8.6注意事项

在Go语言中,panic 是一种表示程序发生严重错误的机制。当程序在运行时遇到无法处理的错误或异常情况时,它会触发 panic,导致程序立即终止。panic 是一种在运行时引发的异常,类似于其他编程语言中的异常或错误。

关闭一个未初始化的 channel 会产生 panic

重复关闭同一个 channel 会产生 panic

向一个已关闭的 channel 发送消息会产生 panic

从已关闭的 channel 读取消息永远不会阻塞,并且会返回一个为 false 的值,用以判断该 channel 是否已关闭(x,ok := <- ch)

channel 在 Golang 中是一等公民,它是线程安全的,面对并发问题,应首先想到 channel。

1.9 go-zero框架

1.9.1安装

可参考:https://go-zero.dev/docs/tasks

1.9.2初体验

1.先创建一个名为godemo_dev的go项目,我这里用的是idea创建,goland和vscode都可以,

2.创建一个godemo_dev.api的文件

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

3.在godemo_dev.api文件中输入以下内容

syntax = "v1"
type (
	//1.用户表
	AddSysUserRequest {
		Name     string `json:"realname"`
		Username string `json:"username"`
		Address  string `json:"address"`
		Status   int64  `json:"status"` //0代表启用,1代表禁用
		Password string `json:"password"`
		Sex      int64  `json:"sex"` //0代表男,1代表女
		Phone    string `json:"phone"`
		Email    string `json:"email"`
		CreateBy int64  `json:"createBy"`
	}

	AddSysUserResponse {
		Id int64 `json:"id"`
	}

	DeleteSysUserRequest {
		Id int64 `path:"id"`
	}

	UpdateSysUserRequest {
		Id       int64  `json:"id"`
		Username string `json:"username"`
		Address  string `json:"address"`
		Status   int64  `json:"status"`
		Password string `json:"password"`
		Sex      int64  `json:"sex"`
		Phone    string `json:"phone"`
		Email    string `json:"email"`
		CreateBy int64  `json:"createBy"`
	}
	Page {
		CurPage  int64 `json:"curPage,default=1"`
		PageSize int64 `json:"pageSize,default=10"`
	}
	QuerySysUserRequest {
		Keyword string `json:"keyword,optional"`
		Page
	}

	QuerySysUserResponse {
		List  interface{} `json:"list"`
		Total int64       `json:"total"`
	}
)
service godemo_dev {
	//1.系统用户模块
	@doc(summary: "增加系统用户")
	@handler addSysUser
	post /sys-user/add (AddSysUserRequest)

	@doc(summary: "删除系统用户")
	@handler deleteSysUser
	delete /sys-user/delete/:id (DeleteSysUserRequest)

	@doc(summary: "更新系统用户")
	@handler updateSysUser
	put /sys-user/update (UpdateSysUserRequest)

	@doc(summary: "查询系统用户")
	@handler querySysUser
	post /sys-user/query (QuerySysUserRequest) returns (QuerySysUserResponse)
}

4.执行以下命令

goctl api go -api godemo_dev.api -dir .

项目结构目录如下

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

这个时候如果发现代码报错,可以删除go.mod,重新生成依赖

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

打开终端执行下面的命令,重新导入依赖

go mod init godemo_dev

go mod tidy

此时代码已经不报错了,

5.在项目中创建model文件夹

然后在终端进入到model目录,执行命令

goctl model mysql datasource --url “root:5KF4q9eVa4nSCW8s@tc
p(rm-uf69e6h3153bmc184ao.mysql.rds.aliyuncs.com:3306)/meijumeihu” -t=‘sys_user’ .

此时自动生成数据库相关代码

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

6 最后需要修改两个文件

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

修改的文件如下

config.go修改如下

package config
import (
	"github.com/zeromicro/go-zero/core/stores/redis"
	"github.com/zeromicro/go-zero/rest"
)
type Config struct {
	rest.RestConf
	MysqlUrl string  //添加mysql连接
}

servicecontext.go修改如下

package svc

import (
	"github.com/zeromicro/go-zero/core/stores/sqlx"
	"godemo/internal/config"
	"godemo/model"
)

type ServiceContext struct {
	SysUserModel model.SysUserModel
	Config       config.Config
}

func NewServiceContext(c config.Config) *ServiceContext {
	conn := sqlx.NewMysql(c.MysqlUrl)
	sysUserModel := model.NewSysUserModel(conn)
	return &ServiceContext{
		SysUserModel: sysUserModel,
		Config:       c,
	}
}

最后修改以下我们的配置文件godemodev.yaml

Name: godemo_dev
Host: 0.0.0.0
Port: 8888
MysqlUrl: "root:5KF4q9eVa4nSCW8s@tcp(rm-uf69e6h3153bmc184ao.mysql.rds.aliyuncs.com:3306)/meijumeihu?charset=utf8mb4&parseTime=true&loc=Asia%2FShanghai"

go-zero配置基本完毕,剩下的就是撸代码了

2.0 结构体和接口

2.0.1结构体

结构体是一种聚合类型,里面可以包含任意类型的值,这些值就是我们定义的结构体的成员,也称为字段。在 Go 语言中,要自定义一个结构体,需要使用 type+struct 关键字组合。

定义格式:

type structName struct{
    fieldName typeName
    ....
    ....
}
  • type 和 struct 是 Go 语言的关键字,二者组合就代表要定义一个新的结构体类型。
  • structName 是结构体类型的名字。
  • fieldName 是结构体的字段名,而 typeName 是对应的字段类型。
  • 字段可以是零个、一个或者多个
  • 小提示:结构体也是一种类型,所以以后自定义的结构体,我会称为某结构体或某类型,两者是一个意思。比如 person 结构体和 person 类型其实是一个意思.

示例代码

package main
import "fmt"
func main() {
	type Person struct {
		name string
		age  int
	}
	var p Person
	p.age = 12
	p.name = "张三"
	fmt.Println(p)
    //默认初始化
	p := Person{"飞雪无情",30}
    //不按顺序初始化
    p:=person{age:30,name:"飞雪无情"}
	fmt.Println(p.name,p.age)
}

结构体可以定义任何类型,包括自定义的类型

type person struct {
    name string
    age uint
    addr address  //自定义类型
}

type address struct {
    province string
    city string
}
 p:=person{
        age:30,
        name:"飞雪无情",
        addr:address{
            province: "北京",
            city:     "北京",
        },
    }
//用.访问
fmt.Println(p.addr.province)

2.0.2 接口

接口是和调用方的一种约定,它是一个高度抽象的类型,不用和具体的实现细节绑定在一起。接口要做的是定义好约定,告诉调用方自己可以做什么,但不用知道它的内部实现,这和我们见到的具体的类型如 int、map、slice 等不一样。

接口定义:

type 接口名 interface {

String() string //通过String()返回一个字符串

}

举个例子,我现在定义一个接口

type Stringer interface { //定义一个接口
    String() string
}
func (p person) String()  string{  //实现这个接口,方法名返回值一样
    return fmt.Sprintf("the name is %s,age is %d",p.name,p.age)
}
func (addr address) String()  string{  //address也实现这个接口
    return fmt.Sprintf("the addr is %s%s",addr.province,addr.city)
}

给结构体类型 person 定义一个方法,这个方法和接口里方法的签名(名称、参数和返回值)一样,这样结构体 person 就实现了 Stringer 接口。

注意:如果一个接口有多个方法,那么需要实现接口的每个方法才算是实现了这个接口。

我们在定义一个打印的函数pringString

func printString(s fmt.Stringer){
    fmt.Println(s.String())
}

这个被定义的函数 printString,它接收一个 Stringer 接口类型的参数,然后打印出 Stringer 接口的 String 方法返回的字符串。printString 这个函数的优势就在于它是面向接口编程的,只要一个类型实现了 Stringer 接口,都可以打印出对应的字符串,而不用管具体的类型实现。提高了接口的复用性

同一个接口被两个类型实现,都可以实现

printString(p)
printString(p.addr)

这就是面向接口的好处,只要定义和调用双方满足约定,就可以使用,而不用管具体实现。接口的实现者也可以更好的升级重构,而不会有任何影响,因为接口约定没有变。

值接收者和指针接收者

我们已经知道,如果要实现一个接口,必须实现这个接口提供的所有方法,而且在上节课讲解方法的时候,我们也知道定义一个方法,有值类型接收者和指针类型接收者两种。二者都可以调用方法,因为 Go 语言编译器自动做了转换,所以值类型接收者和指针类型接收者是等价的。但是在接口的实现中,值类型接收者和指针类型接收者不一样我们可以理解为:

值类型接收者实现接口的时候,不管是类型本身,还是该类型的指针类型,都实现了该接口

printString(&p) 能正常运行

工厂函数:用于自定义的结构体,工厂函数也可以用来创建一个接口,它的好处就是可以隐藏内部具体类型的实现,让调用者只需关注接口的使用即可。

go中没有继承得概念,我们可以使用组合来实现父子级的关系。

类型断言:有了接口和实现接口的类型,就会有类型断言。类型断言用来判断一个接口的值是否是实现该接口的某个具体类型。

	fmt.Println(p1.name)
	var s fmt.Stringer
	s = p1
	p2 := s.(*person)
	fmt.Println(p2)
	a, ok := s.(address)  //断言判断
	if ok {
		fmt.Println(a)
	} else {
		fmt.Println("s不是一个address")
	}

2.1 错误处理

这个比较简单,就分类两种error和panic

Deferred函数:关闭文件使用在一个自定义函数中,你打开了一个文件,然后需要关闭它以释放资源。不管你的代码执行了多少分支,是否出现了错误,文件是一定要关闭的,这样才能保证资源的释放。如果这个事情由开发人员来做,随着业务逻辑的复杂会变得非常麻烦,而且还有可能会忘记关闭。基于这种情况,Go 语言为我们提供了 defer 函数,可以保证文件关闭后一定会被执行,不管你自定义的函数出现异常还是错误。

package main

import (
	"fmt"
	"os"
)
func main() {
	//创建一个文件
	file, err := os.Create("example.txt")
	if err != nil {
		fmt.Println("Error creating file:", err)
	}
	//写文件
	//定义一个字节切片
	data := []byte("hello,this is some content to write to the file")
	_, err = file.Write(data)
	//读取文件
	readFile, err := os.ReadFile("example.txt")
	fmt.Printf("file content : %s\n", string(readFile))
	if err != nil {
		return
	}
	//两种都可以
	err = file.Close()
	//关闭文件
	defer func(file *os.File) {
		err = file.Close()
		if err != nil {
			fmt.Println("Open File Fail")
		}
	}(file)
}
//创建
os.Create("example.txt")
//写
file.Write(data)
//读
os.ReadFile("example.txt")
//关闭
file.Close()

需要注意的是:

  1. 在一个方法或者函数中,可以有多个 defer 语句;
  2. 多个 defer 语句的执行顺序依照后进先出的原则。

defer 有一个调用栈,越早定义越靠近栈的底部,越晚定义越靠近栈的顶部

2.2并发,进程,线程

2.2.1并发

前面的例子中中,我所写的代码都按照顺序执行,也就是上一句代码执行完,才会执行下一句,这样的代码逻辑简单,也符合我们的阅读习惯。但这样是不够的,因为计算机很强大,如果只让它干完一件事情再干另外一件事情就太浪费了。比如一款音乐软件,使用它听音乐的时候还想让它下载歌曲,同一时刻做了两件事,在编程中,这就是并发,并发可以让你编写的程序在同一时刻做多几件事情。

2.2.2进程

在操作系统中,进程是一个非常重要的概念。当你启动一个软件(比如浏览器)的时候,操作系统会为这个软件创建一个进程,这个进程是该软件的工作空间,它包含了软件运行所需的所有资源,比如内存空间、文件句柄,还有下面要讲的线程等

2.2.3线程

线程是进程的执行空间,一个进程可以有多个线程,线程被操作系统调度执行,比如下载一个文件,发送一个消息等。这种多个线程被操作系统同时调度执行的情况,就是多线程的并发。一个程序启动,就会有对应的进程被创建,同时进程也会启动一个线程,这个线程叫作主线程。如果主线程结束,那么整个程序就退出了。有了主线程,就可以从主线里启动很多其他线程,也就有了多线程的并发。

2.3 sync同步

2.3.1资源竞争

在一个 goroutine 中,如果分配的内存没有被其他 goroutine 访问,只在该 goroutine 中被使用,那么不存在资源竞争的问题。

但如果同一块内存被多个 goroutine 同时访问,就会产生不知道谁先访问也无法预料最后结果的情况。这就是资源竞争,这块内存可以称为共享的资源。

//共享的资源
var result = 0
func main() {
    // 开启一百个协程
	for i := 0; i < 100; i++ {
		go add(10)
	}
    //防止提前退出
	time.Sleep(2 * time.Second)
	fmt.Println("和为", result)
}
func add(i int) {
	result += i
}

“和为 1000”,但当运行程序后,可能如预期所示,但也可能是 990 或者 980。导致这种情况的核心原因是资源 sum 不是并发安全的,因为同时会有多个协程交叉执行 sum+=i,产生不可预料的结果。既然已经知道了原因,解决的办法也就有了,只需要确保同时只有一个协程执行 sum+=i 操作即可。要达到该目的,可以使用 sync.Mutex 互斥锁。

小技巧:使用 go build、go run、go test 这些 Go 语言工具链提供的命令时,添加 -race 标识可以帮你检查 Go 语言代码是否存在资源竞争。

同步原语

sync.Mutex

互斥锁,顾名思义,指的是在同一时刻只有一个协程执行某段代码,其他协程都要等待该协程执行完毕后才能继续执行。

在下面的示例中,我声明了一个互斥锁 mutex,然后修改 add 函数,对 sum+=i 这段代码加锁保护。这样这段访问共享资源的代码片段就并发安全了,可以得到正确的结果。

var(
   sum int
   mutex sync.Mutex
)
func add(i int) {
   mutex.Lock()
   sum += i
   mutex.Unlock()
}

小提示:以上被加锁保护的 sum+=i 代码片段又称为临界区。在同步的程序设计中,临界区段指的是一个访问共享资源的程序片段,而这些共享资源又有无法同时被多个协程访问的特性。 当有协程进入临界区段时,其他协程必须等待,这样就保证了临界区的并发安全。

互斥锁的使用非常简单,它只有两个方法 Lock 和 Unlock,代表加锁和解锁。当一个协程获得 Mutex 锁后,其他协程只能等到 Mutex 锁释放后才能再次获得锁。

Mutex 的 Lock 和 Unlock 方法总是成对出现,而且要确保 Lock 获得锁后,一定执行 UnLock 释放锁,所以在函数或者方法中会采用 defer 语句释放锁,如下面的代码所示:

func add(i int) {
   mutex.Lock()
   defer mutex.Unlock()  // 一定要解锁操作
   sum += i
}

在 sync.Mutex 小节中,我对共享资源 sum 的加法操作进行了加锁,这样可以保证在修改 sum 值的时候是并发安全的。如果读取操作也采用多个协程呢?

var (
	result = 0
	mutex  sync.Mutex
)

func main() {
	for i := 0; i < 100; i++ {
		go add(10)
	}
	for i := 0; i < 10; i++ {
		go fmt.Println("和为", readResult())
	}
	time.Sleep(2 * time.Second)
	fmt.Println("和为", result)
}
func add(i int) {
	mutex.Lock()  //枷锁
	result += i
	defer mutex.Unlock() //解锁
}
func readResult() int {
	mutex.Lock()
	defer mutex.Unlock()
	b := result
	return b
}

因为 add 和 readSum 函数使用的是同一个 sync.Mutex,所以它们的操作是互斥的,也就是一个 goroutine 进行修改操作 sum+=i 的时候,另一个 gouroutine 读取 sum 的操作 b:=sum 会等待,直到修改操作执行完毕。

现在我们解决了多个 goroutine 同时读写的资源竞争问题,但是又遇到另外一个问题——性能。因为每次读写共享资源都要加锁,所以性能低下,这该怎么解决呢?

现在我们分析读写这个特殊场景,有以下几种情况:

  1. 写的时候不能同时读,因为这个时候读取的话可能读到脏数据(不正确的数据);
  2. 读的时候不能同时写,因为也可能产生不可预料的结果;
  3. 读的时候可以同时读,因为数据不会改变,所以不管多少个 goroutine 读都是并发安全的。

所以就可以通过读写锁 sync.RWMutex 来优化这段代码,提升性能。现在我将以上示例改为读写锁

var mutex sync.RWMutex
func readSum() int {
   //只获取读锁
   mutex.RLock()
   defer mutex.RUnlock()
   b:=sum
   return b

}

对比互斥锁的示例,读写锁的改动有两处:

  1. 把锁的声明换成读写锁 sync.RWMutex。
  2. 把函数 readSum 读取数据的代码换成读锁,也就是 RLock 和 RUnlock。

这样性能就会有很大的提升,因为多个 goroutine 可以同时读数据,不再相互等待。

sync.WaitGroup

在以上示例中,相信你注意到了这段 time.Sleep(2 * time.Second) 代码,这是为了防止主函数 main 返回使用,一旦 main 函数返回了,程序也就退出了。

因为我们不知道 100 个执行 add 的协程和 10 个执行 readSum 的协程什么时候完全执行完毕,所以设置了一个比较长的等待时间,也就是两秒。

小提示:一个函数或者方法的返回 (return) 也就意味着当前函数执行完毕。

所以存在一个问题,如果这 110 个协程在两秒内执行完毕,main 函数本该提前返回,但是偏偏要等两秒才能返回,会产生性能问题。

如果这 110 个协程执行的时间超过两秒,因为设置的等待时间只有两秒,程序就会提前返回,导致有协程没有执行完毕,产生不可预知的结果。

那么有没有办法解决这个问题呢?也就是说有没有办法监听所有协程的执行,一旦全部执行完毕,程序马上退出,这样既可保证所有协程执行完毕,又可以及时退出节省时间,提升性能。你第一时间应该会想到上节课讲到的 channel。没错,channel 的确可以解决这个问题,不过非常复杂,Go 语言为我们提供了更简洁的解决办法,它就是 sync.WaitGroup。

func main() {
	run()
}
func add(i int) {
	mutex.Lock()
	result += i
	mutex.Unlock()
}
var log sync.RWMutex
func readResult() int {
	log.RLock()
	defer log.RUnlock()
	b := result
	return b
}
fun run() {
var wg sync.WaitGroup

   //因为要监控110个协程,所以设置计数器为110
   wg.Add(110)
   for i := 0; i < 100; i++ {
      go func() {
         //计数器值减1
         defer wg.Done()
         add(10)
      }()
   }
   for i:=0; i<10;i++ {
      go func() {
         //计数器值减1
         defer wg.Done()
         fmt.Println("和为:",readSum())
      }()
   }
   //一直等待,只要计数器值为0
   wg.Wait()
}

sync.WaitGroup 的使用比较简单,一共分为三步:

  1. 声明一个 sync.WaitGroup,然后通过 Add 方法设置计数器的值,需要跟踪多少个协程就设置多少,这里是 110;
  2. 在每个协程执行完毕时调用 Done 方法,让计数器减 1,告诉 sync.WaitGroup 该协程已经执行完毕;
  3. 最后调用 Wait 方法一直等待,直到计数器值为 0,也就是所有跟踪的协程都执行完毕。

通过 sync.WaitGroup 可以很好地跟踪协程。在协程执行完毕后,整个 run 函数才能执行完毕,时间不多不少,正好是协程执行的时间。

sync.WaitGroup 适合协调多个协程共同做一件事情的场景,比如下载一个文件,假设使用 10 个协程,每个协程下载文件的 1⁄10 大小,只有 10 个协程都下载好了整个文件才算是下载好了。这就是我们经常听到的多线程下载,通过多个线程共同做一件事情,显著提高效率。

小提示:其实你也可以把 Go 语言中的协程理解为平常说的线程,从用户体验上也并无不可,但是从技术实现上,你知道他们是不一样的就可以了。

sync.Once

在实际的工作中,你可能会有这样的需求:让代码只执行一次,哪怕是在高并发的情况下,比如创建一个单例。

针对这种情形,Go 语言为我们提供了 sync.Once 来保证代码只执行一次,如下所示:

func main() {
   doOnce()
}
func doOnce() {
   var once sync.Once
   onceBody := func() {
      fmt.Println("Only once")
   }
   //用于等待协程执行完毕
   done := make(chan bool)
   //启动10个协程执行once.Do(onceBody)
   for i := 0; i < 10; i++ {
      go func() {
         //把要执行的函数(方法)作为参数传给once.Do方法即可
         once.Do(onceBody)
         done <- true
      }()
   }
   for i := 0; i < 10; i++ {
      <-done
   }
}

这是 Go 语言自带的一个示例,虽然启动了 10 个协程来执行 onceBody 函数,但是因为用了 once.Do 方法,所以函数 onceBody 只会被执行一次。也就是说在高并发的情况下,sync.Once 也会保证 onceBody 函数只执行一次。

sync.Once 适用于创建某个对象的单例、只加载一次的资源等只执行一次的场景。

sync.Cond

在 Go 语言中,sync.WaitGroup 用于最终完成的场景,关键点在于一定要等待所有协程都执行完毕。

而 sync.Cond 可以用于发号施令,一声令下所有协程都可以开始执行,关键点在于协程开始的时候是等待的,要等待 sync.Cond 唤醒才能执行。

sync.Cond 从字面意思看是条件变量,它具有阻塞协程和唤醒协程的功能,所以可以在满足一定条件的情况下唤醒协程,但条件变量只是它的一种使用场景。

下面我以 10 个人赛跑为例来演示 sync.Cond 的用法。在这个示例中有一个裁判,裁判要先等这 10 个人准备就绪,然后一声发令枪响,这 10 个人就可以开始跑了,如下所示:

//10个人赛跑,1个裁判发号施令
func race(){

   cond :=sync.NewCond(&sync.Mutex{})
   var wg sync.WaitGroup
   wg.Add(11)

   for i:=0;i<10; i++ {
      go func(num int) {
         defer  wg.Done()
         fmt.Println(num,"号已经就位")
         cond.L.Lock()
         cond.Wait()//等待发令枪响
         fmt.Println(num,"号开始跑……")
         cond.L.Unlock()
      }(i)
   }

   //等待所有goroutine都进入wait状态
   time.Sleep(2*time.Second)

   go func() {
      defer  wg.Done()
      fmt.Println("裁判已经就位,准备发令枪")
      fmt.Println("比赛开始,大家准备跑")
      cond.Broadcast()//发令枪响
   }()
   //防止函数提前返回退出
   wg.Wait()
}

以上示例中有注释说明,已经很好理解,我这里再大概讲解一下步骤:

  1. 通过 sync.NewCond 函数生成一个 *sync.Cond,用于阻塞和唤醒协程;
  2. 然后启动 10 个协程模拟 10 个人,准备就位后调用 cond.Wait() 方法阻塞当前协程等待发令枪响,这里需要注意的是调用 cond.Wait() 方法时要加锁;
  3. time.Sleep 用于等待所有人都进入 wait 阻塞状态,这样裁判才能调用 cond.Broadcast() 发号施令;
  4. 裁判准备完毕后,就可以调用 cond.Broadcast() 通知所有人开始跑了。

sync.Cond 有三个方法,它们分别是:

  1. Wait,阻塞当前协程,直到被其他协程调用 Broadcast 或者 Signal 方法唤醒,使用的时候需要加锁,使用 sync.Cond 中的锁即可,也就是 L 字段。
  2. Signal,唤醒一个等待时间最长的协程。
  3. Broadcast,唤醒所有等待的协程。

注意:在调用 Signal 或者 Broadcast 之前,要确保目标协程处于 Wait 阻塞状态,不然会出现死锁问题。

如果你以前学过 Java,会发现 sync.Cond 和 Java 的等待唤醒机制很像,它的三个方法 Wait、Signal、Broadcast 就分别对应 Java 中的 wait、notify、notifyAll。

同步原语通常用于更复杂的并发控制,如果追求更灵活的控制方式和性能,你可以使用它们

本节课到这里就要结束了,sync 包里还有一个同步原语我没有讲,它就是 sync.Map。sync.Map 的使用和内置的 map 类型一样,只不过它是并发安全的,所以这节课的作业就是练习使用 sync.Map。

sync.Map 的方法。

  1. Store:存储一对 key-value 值。

  2. Load:根据 key 获取对应的 value 值,并且可以判断 key 是否存在。

  3. LoadOrStore:如果 key 对应的 value 存在,则返回该 value;如果不存在,存储相应的 value。

  4. Delete:删除一个 key-value 键值对。

  5. Range:循环迭代 sync.Map,效果与 for range 一样。

2.4 context: 上下文对象

先讨论一个问题:一个协程启动后,大部分情况需要等待里面的代码执行完毕,然后协程会自行退出。但是如果有一种情景,需要让协程提前退出怎么办呢?在下面的代码中,我做了一个监控狗用来监控程序:

func main() {
	var wg sync.WaitGroup
	wg.Add(1)
	stopCh := make(chan bool) //用于停止监控狗
	go func() {
		defer wg.Done()
		watchDog(stopCh, "监控狗1")
	}()
	time.Sleep(5 * time.Second)
	stopCh <- true
	wg.Wait()
}
func watchDog(stopCh chan bool, name string) {
	for {
		select {
		case <-stopCh:
			fmt.Println(name, "停止指令已收到,马上停止")
			return
		default:
			fmt.Println(name, "正在监控")
		}
		time.Sleep(1 * time.Second)
	}
}

这个示例是使用 select+channel 的方式改造的 watchDog 函数,实现了通过 channel 发送指令让监控狗停止,进而达到协程退出的目的。以上示例主要有两处修改,具体如下:

  1. 为 watchDog 函数增加 stopCh 参数,用于接收停止指令;

  2. 在 main 函数中,声明用于停止的 stopCh,传递给 watchDog 函数,然后通过 stopCh<-true 发送停止指令让协程退出。

通过 select+channel 让协程退出的方式比较优雅,但是如果我们希望做到同时取消很多个协程呢?如果是定时取消协程又该怎么办?这时候 select+channel 的局限性就凸现出来了,即使定义了多个 channel 解决问题,代码逻辑也会非常复杂、难以维护。

要解决这种复杂的协程问题,必须有一种可以跟踪协程的方案,只有跟踪到每个协程,才能更好地控制它们,这种方案就是 Go 语言标准库为我们提供的 Context,也是这节课的主角。

现在我通过 Context 重写上面的示例,实现让监控狗停止的功能,

func main() {
	var wg sync.WaitGroup
	wg.Add(1)
	ctx, stop := context.WithCancel(context.Background())
	go func() {
		defer wg.Done()
		watchDog(ctx, "监控狗1")
	}()
	time.Sleep(5 * time.Second)
	stop()
	wg.Wait()
}
func watchDog(ctx context.Context, name string) {
	for {
		select {
		case <-ctx.Done():
			fmt.Println(name, "停止指令已收到,马上停止")
			return
		default:
			fmt.Println(name, "正在监控")
		}
		time.Sleep(1 * time.Second)
	}
}

相比 select+channel 的方案,Context 方案主要有 4 个改动点。

  1. watchDog 的 stopCh 参数换成了 ctx,类型为 context.Context。
  2. 原来的 case <-stopCh 改为 case <-ctx.Done(),用于判断是否停止。
  3. 使用 context.WithCancel(context.Background()) 函数生成一个可以取消的 Context,用于发送停止指令。这里的 context.Background() 用于生成一个空 Context,一般作为整个 Context 树的根节点。
  4. 原来的 stopCh <- true 停止指令,改为 context.WithCancel 函数返回的取消函数 stop()。

可以看到,这和修改前的整体代码结构一样,只不过从 channel 换成了 Context。以上示例只是 Context 的一种使用场景,它的能力不止于此,现在我来介绍什么是 Context。

2.4.1什么是 Context

一个任务会有很多个协程协作完成,一次 HTTP 请求也会触发很多个协程的启动,而这些协程有可能会启动更多的子协程,并且无法预知有多少层协程、每一层有多少个协程。

如果因为某些原因导致任务终止了,HTTP 请求取消了,那么它们启动的协程怎么办?该如何取消呢?因为取消这些协程可以节约内存,提升性能,同时避免不可预料的 Bug。

Context 就是用来简化解决这些问题的,并且是并发安全的。Context 是一个接口,它具备手动、定时、超时发出取消信号、传值等功能,主要用于控制多个协程之间的协作,尤其是取消操作。一旦取消指令下达,那么被 Context 跟踪的这些协程都会收到取消信号,就可以做清理和退出操作。

type Context interface {

   Deadline() (deadline time.Time, ok bool)

   Done() <-chan struct{}

   Err() error

   Value(key interface{}) interface{}

}
  1. Deadline 方法可以获取设置的截止时间,第一个返回值 deadline 是截止时间,到了这个时间点,Context 会自动发起取消请求,第二个返回值 ok 代表是否设置了截止时间。
  2. Done 方法返回一个只读的 channel,类型为 struct{}。在协程中,如果该方法返回的 chan 可以读取,则意味着 Context 已经发起了取消信号。通过 Done 方法收到这个信号后,就可以做清理操作,然后退出协程,释放资源。
  3. Err 方法返回取消的错误原因,即因为什么原因 Context 被取消。
  4. Value 方法获取该 Context 上绑定的值,是一个键值对,所以要通过一个 key 才可以获取对应的值。

Context 接口的四个方法中最常用的就是 Done 方法,它返回一个只读的 channel,用于接收取消信号。当 Context 取消的时候,会关闭这个只读 channel,也就等于发出了取消信号。

2.4.2Context 树

我们不需要自己实现 Context 接口,Go 语言提供了函数可以帮助我们生成不同的 Context,通过这些函数可以生成一颗 Context 树,这样 Context 才可以关联起来,父 Context 发出取消信号的时候,子 Context 也会发出,这样就可以控制不同层级的协程退出。

从使用功能上分,有四种实现好的 Context。

  1. 空 Context:不可取消,没有截止时间,主要用于 Context 树的根节点。
  2. 可取消的 Context:用于发出取消信号,当取消的时候,它的子 Context 也会取消。
  3. 可定时取消的 Context:多了一个定时的功能。
  4. 值 Context:用于存储一个 key-value 键值对。

从下图 Context 的衍生树可以看到,最顶部的是空 Context,它作为整棵 Context 树的根节点,在 Go 语言中,可以通过 context.Background() 获取一个根节点 Context。

Drawing 1.png

(四种 Context 的衍生树)

有了根节点 Context 后,这颗 Context 树要怎么生成呢?需要使用 Go 语言提供的四个函数。

  1. WithCancel(parent Context):生成一个可取消的 Context。
  2. WithDeadline(parent Context, d time.Time):生成一个可定时取消的 Context,参数 d 为定时取消的具体时间。
  3. WithTimeout(parent Context, timeout time.Duration):生成一个可超时取消的 Context,参数 timeout 用于设置多久后取消
  4. WithValue(parent Context, key, val interface{}):生成一个可携带 key-value 键值对的 Context。

以上四个生成 Context 的函数中,前三个都属于可取消的 Context,它们是一类函数,最后一个是值 Context,用于存储一个 key-value 键值对。

使用context取消协程

取消多个协程也比较简单,把 Context 作为参数传递给协程即可

wg.Add(3)

go func() {

   defer wg.Done()

   watchDog(ctx,"【监控狗2】")

}()

go func() {

   defer wg.Done()

   watchDog(ctx,"【监控狗3】")

}()

示例中增加了两个监控狗,也就是增加了两个协程,这样一个 Context 就同时控制了三个协程,一旦 Context 发出取消信号,这三个协程都会取消退出。

以上示例中的 Context 没有子 Context,如果一个 Context 有子 Context,在该 Context 取消时会发生什么呢?下面通过一幅图说明:

Drawing 3.png

(Context 取消)

可以看到,当节点 Ctx2 取消时,它的子节点 Ctx4、Ctx5 都会被取消,如果还有子节点的子节点,也会被取消。也就是说根节点为 Ctx2 的所有节点都会被取消,其他节点如 Ctx1、Ctx3 和 Ctx6 则不会。

Context 不仅可以取消,还可以传值,通过这个能力,可以把 Context 存储的值供其他协程使用。

完成代码如下

func main() {
	var wg sync.WaitGroup
	wg.Add(4)
	ctx, stop := context.WithCancel(context.Background())
	go func() {
		defer wg.Done()
		watchDog(ctx, "监控狗1")
	}()
	go func() {
		defer wg.Done()
		watchDog(ctx, "监控狗2")
	}()
	go func() {
		defer wg.Done()
		watchDog(ctx, "监控狗3")
	}()
    //存值
	valCtx := context.WithValue(ctx, "userId", 2)
	go func() {
		defer wg.Done()
		getUser(valCtx)
	}()
	time.Sleep(5 * time.Second)
	stop()
	wg.Wait()
}
func watchDog(ctx context.Context, name string) {
	for {
		select {
		case <-ctx.Done():
			fmt.Println(name, "停止指令已收到,马上停止")
			return
		default:
			fmt.Println(name, "正在监控")
		}
		time.Sleep(1 * time.Second)
	}
}
func getUser(ctx context.Context) {
	for {
		select {
		case <-ctx.Done():
			fmt.Println("获取用户,协程退出")
			return
		default:
            //取值
			userId := ctx.Value("userId")
			fmt.Println("获取用户", "用户id为:", userId)
			time.Sleep(1 * time.Second)
		}

	}
}

2.4.3Context 使用原则

Context 是一种非常好的工具,使用它可以很方便地控制取消多个协程。在 Go 语言标准库中也使用了它们,比如 net/http 中使用 Context 取消网络的请求。

要更好地使用 Context,有一些使用原则需要尽可能地遵守。

  1. Context 不要放在结构体中,要以参数的方式传递。
  2. Context 作为函数的参数时,要放在第一位,也就是第一个参数。
  3. 要使用 context.Background 函数生成根节点的 Context,也就是最顶层的 Context。
  4. Context 传值要传递必须的值,而且要尽可能地少,不要什么都传。
  5. Context 多协程安全,可以在多个协程中放心使用。

以上原则是规范类的,Go 语言的编译器并不会做这些检查,要靠自己遵守。Context 通过 With 系列函数生成 Context 树,把相关的 Context 关联起来,这样就可以统一进行控制。一声令下,关联的 Context 都会发出取消信号,使用这些 Context 的协程就可以收到取消信号,然后清理退出。你在定义函数的时候,如果想让外部给你的函数发取消信号,就可以为这个函数增加一个 Context 参数,让外部的调用者可以通过 Context 进行控制,比如下载一个文件超时退出的需求。

要想跟踪一个用户的请求,必须有一个唯一的 ID 来标识这次请求调用了哪些函数、执行了哪些代码,然后通过这个唯一的 ID 把日志信息串联起来。这样就形成了一个日志轨迹,也就实现了用户的跟踪,于是思路就有了。

  1. 在用户请求的入口点生成 TraceID。
  2. 通过 context.WithValue 保存 TraceID。
  3. 然后这个保存着 TraceID 的 Context 就可以作为参数在各个协程或者函数间传递。
  4. 在需要记录日志的地方,通过 Context 的 Value 方法获取保存的 TraceID,然后把它和其他日志信息记录下来。
  5. 这样具备同样 TraceID 的日志就可以被串联起来,达到日志跟踪的目的。

以上思路实现的核心是 Context 的传值功能。

2.5 并发模式

目前我们已熟练掌握了 goroutine、channel、sync 包的同步原语,这些都是并发编程比较基础的元素。而这节课要介绍的是如何用这些基础元素组成并发模式,帮助我们更好地编写并发程序。

2.5.1for select 循环模式

非常常见,在前面的课程中也使用过,它一般和 channel 组合完成任务,代码格式如下:

for { //for无限循环,或者for range循环

  select {

    //通过一个channel控制

  }

}

这是一种 for 循环 +select 多路复用的并发模式,哪个 case 满足就执行哪个,直到满足一定的条件退出 for 循环(比如发送退出信号)。

从具体实现上讲,for select 循环有两种模式,一种是上节课监控狗例子中的无限循环模式,只有收到终止指令才会退出,如下所示:

for  {

   select {

   case <-done:

      return

   default:

      //执行具体的任务

   }

 }

这种模式会一直执行 default 语句中的任务,直到 done 这个 channel 被关闭为止。

第二种模式是 for range select 有限循环,一般用于把可以迭代的内容发送到 channel 上,如下所示:

for _,s:=range []int{}{

   select {

   case <-done:

      return
   case resultCh <- s:
   }

}

这种模式也会有一个 done channel,用于退出当前的 for 循环,而另外一个 resultCh channel 用于接收 for range 循环的值,这些值通过 resultCh 可以传送给其他的调用者。

2.5.2select timeout 模式

假如需要访问服务器获取数据,因为网络的不同响应时间不一样,为保证程序的质量,不可能一直等待网络返回,所以需要设置一个超时时间,这时候就可以使用 select timeout 模式,如下所示:

func main() {

   result := make(chan string)

   go func() {

      //模拟网络访问

      time.Sleep(8 * time.Second)

      result <- "服务端结果"

   }()

   select {

   case v := <-result:

      fmt.Println(v)

   case <-time.After(5 * time.Second):

      fmt.Println("网络访问超时了")

   }

}

select timeout 模式的核心在于通过 time.After 函数设置一个超时时间,防止因为异常造成 select 语句的无限等待。

小提示:如果可以使用 Context 的 WithTimeout 函数超时取消,要优先使用。

2.5.3Pipeline 模式(流水线)

Pipeline 模式也称为流水线模式,模拟的就是现实世界中的流水线生产。以手机组装为例,整条生产流水线可能有成百上千道工序,每道工序只负责自己的事情,最终经过一道道工序组装,就完成了一部手机的生产。

从技术上看,每一道工序的输出,就是下一道工序的输入,在工序之间传递的东西就是数据,这种模式称为流水线模式,而传递的数据称为数据流。

图片1.png

(流水线模式)

通过以上流水线模式示意图,可以看到从最开始的生产,经过工序 1、2、3、4 到最终成品,这就是一条比较形象的流水线,也就是 Pipeline。

现在我以组装手机为例,讲解流水线模式的使用。假设一条组装手机的流水线有 3 道工序,分别是配件采购配件组装打包成品,如图所示:

图片2.png

(手机组装流水线)

从以上示意图中可以看到,采购的配件通过 channel 传递给工序 2 进行组装,然后再通过 channel 传递给工序 3 打包成品。相对工序 2 来说,工序 1 是生产者,工序 3 是消费者。相对工序 1 来说,工序 2 是消费者。相对工序 3 来说,工序 2 是生产者。

//工序1采购

func buy(n int) <-chan string {

   out := make(chan string)

   go func() {

      defer close(out)

      for i := 1; i <= n; i++ {

         out <- fmt.Sprint("配件", i)

      }

   }()

   return out

}

首先我们定义一个采购函数 buy,它有一个参数 n,可以设置要采购多少套配件。采购代码的实现逻辑是通过 for 循环产生配件,然后放到 channel 类型的变量 out 里,最后返回这个 out,调用者就可以从 out 中获得配件。

有了采购好的配件,就可以开始组装了,如下面的代码所示:

//工序2组装

func build(in <-chan string) <-chan string {

   out := make(chan string)

   go func() {

      defer close(out)

      for c := range in {

         out <- "组装(" + c + ")"

      }

   }()

   return out

}

组装函数 build 有一个 channel 类型的参数 in,用于接收配件进行组装,组装后的手机放到 channel 类型的变量 out 中返回。

有了组装好的手机,就可以放在精美的包装盒中售卖了,而包装的操作是工序 3 完成的,对应的函数是 pack,如下所示:

//工序3打包

func pack(in <-chan string) <-chan string {

   out := make(chan string)

   go func() {

      defer close(out)

      for c := range in {

         out <- "打包(" + c + ")"

      }

   }()

   return out

}

函数 pack 的代码实现和组装函数 build 基本相同,这里不再赘述。

流水线上的三道工序都完成后,就可以通过一个组织者把三道工序组织在一起,形成一条完整的手机组装流水线,这个组织者可以是我们常用的 main 函数,如下面的代码所示:

func main() {

   coms := buy(10)    //采购10套配件

   phones := build(coms) //组装10部手机

   packs := pack(phones) //打包它们以便售卖

   //输出测试,看看效果

   for p := range packs {

      fmt.Println(p)

   }

}

按照流水线工序进行调用,最终把手机打包以便售卖,过程如下所示:

打包(组装(配件1))

打包(组装(配件2))

打包(组装(配件3))

打包(组装(配件4))

打包(组装(配件5))

打包(组装(配件6))

打包(组装(配件7))

打包(组装(配件8))

打包(组装(配件9))

打包(组装(配件10))

从上述例子中,我们可以总结出一个流水线模式的构成:

  1. 流水线由一道道工序构成,每道工序通过 channel 把数据传递到下一个工序;
  2. 每道工序一般会对应一个函数,函数里有协程和 channel,协程一般用于处理数据并把它放入 channel 中,整个函数会返回这个 channel 以供下一道工序使用;
  3. 最终要有一个组织者(示例中的 main 函数)把这些工序串起来,这样就形成了一个完整的流水线,对于数据来说就是数据流。

2.5.4扇出和扇入模式

手机流水线经过一段时间的运转,组织者发现产能提不上去,经过调研分析,发现瓶颈在工序 2 配件组装。工序 2 过慢,导致上游工序 1 配件采购速度不得不降下来,下游工序 3 没太多事情做,不得不闲下来,这就是整条流水线产能低下的原因。

为了提升手机产能,组织者决定对工序 2 增加两班人手。人手增加后,整条流水线的示意图如下所示:

图片3.png

(改进后的流水线)

从改造后的流水线示意图可以看到,工序 2 共有工序 2-1、工序 2-2、工序 2-3 三班人手,工序 1 采购的配件会被工序 2 的三班人手同时组装,这三班人手组装好的手机会同时传给merge 组件汇聚,然后再传给工序 3 打包成品。在这个流程中,会产生两种模式:扇出和扇入

  • 示意图中红色的部分是扇出,对于工序 1 来说,它同时为工序 2 的三班人手传递数据(采购配件)。以工序 1 为中点,三条传递数据的线发散出去,就像一把打开的扇子一样,所以叫扇出。
  • 示意图中蓝色的部分是扇入,对于 merge 组件来说,它同时接收工序 2 三班人手传递的数据(组装的手机)进行汇聚,然后传给工序 3。以 merge 组件为中点,三条传递数据的线汇聚到 merge 组件,也像一把打开的扇子一样,所以叫扇入。

小提示:扇出和扇入都像一把打开的扇子,因为数据传递的方向不同,所以叫法也不一样,扇出的数据流向是发散传递出去,是输出流;扇入的数据流向是汇聚进来,是输入流。

已经理解了扇出扇入的原理,就可以开始改造流水线了。这次改造中,三道工序的实现函数 buy、build、pack 都保持不变,只需要增加一个 merge 函数即可,如下面的代码所示:

//扇入函数(组件),把多个chanel中的数据发送到一个channel中

func merge(ins ...<-chan string) <-chan string {

   var wg sync.WaitGroup

   out := make(chan string)

   //把一个channel中的数据发送到out中

   p:=func(in <-chan string) {

      defer wg.Done()

      for c := range in {

         out <- c

      }

   }

   wg.Add(len(ins))

   //扇入,需要启动多个goroutine用于处于多个channel中的数据

   for _,cs:=range ins{

      go p(cs)

   }

   //等待所有输入的数据ins处理完,再关闭输出out

   go func() {

      wg.Wait()

      close(out)

   }()

   return out

}

新增的 merge 函数的核心逻辑就是对输入的每个 channel 使用单独的协程处理,并将每个协程处理的结果都发送到变量 out 中,达到扇入的目的。总结起来就是通过多个协程并发,把多个 channel 合成一个。

在整条手机组装流水线中,merge 函数非常小,而且和业务无关,不能当作一道工序,所以我把它叫作组件。该 merge 组件是可以复用的,流水线中的任何工序需要扇入的时候,都可以使用 merge 组件。

小提示:这次的改造新增了 merge 函数,其他函数保持不变,符合开闭原则。开闭原则规定“软件中的对象(类,模块,函数等等)应该对于扩展是开放的,但是对于修改是封闭的”。

有了可以复用的 merge 组件,现在来看流水线的组织者 main 函数是如何使用扇出和扇入并发模式的,如下所示:

func main() {

   coms := buy(100)    //采购100套配件

   //三班人同时组装100部手机

   phones1 := build(coms)

   phones2 := build(coms)

   phones3 := build(coms)

   //汇聚三个channel成一个

   phones := merge(phones1,phones2,phones3)

   packs := pack(phones) //打包它们以便售卖

   //输出测试,看看效果

   for p := range packs {

      fmt.Println(p)

   }

}

这个示例采购了 100 套配件,也就是开始增加产能了。于是同时调用三次 build 函数,也就是为工序 2 增加人手,这里是三班人手同时组装配件,然后通过 merge 函数这个可复用的组件将三个 channel 汇聚为一个,然后传给 pack 函数打包。

这样通过扇出和扇入模式,整条流水线就被扩充好了,大大提升了生产效率。因为已经有了通用的扇入组件 merge,所以整条流水中任何需要扇出、扇入提高性能的工序,都可以复用 merge 组件做扇入,并且不用做任何修改。

2.5.5Futures 模式

Pipeline 流水线模式中的工序是相互依赖的,上一道工序做完,下一道工序才能开始。但是在我们的实际需求中,也有大量的任务之间相互独立、没有依赖,所以为了提高性能,这些独立的任务就可以并发执行。

举个例子,比如我打算自己做顿火锅吃,那么就需要洗菜、烧水。洗菜、烧水这两个步骤相互之间没有依赖关系,是独立的,那么就可以同时做,但是最后做火锅这个步骤就需要洗好菜、烧好水之后才能进行。这个做火锅的场景就适用 Futures 模式。

Futures 模式可以理解为未来模式,主协程不用等待子协程返回的结果,可以先去做其他事情,等未来需要子协程结果的时候再来取,如果子协程还没有返回结果,就一直等待。我用下面的代码进行演示:

//洗菜

func washVegetables() <-chan string {

   vegetables := make(chan string)

   go func() {

      time.Sleep(5 * time.Second)

      vegetables <- "洗好的菜"

   }()

   return vegetables

}

//烧水

func boilWater() <-chan string {

   water := make(chan string)

   go func() {

      time.Sleep(5 * time.Second)

      water <- "烧开的水"

   }()

   return water

}

洗菜和烧水这两个相互独立的任务可以一起做,所以示例中通过开启协程的方式,实现同时做的功能。当任务完成后,结果会通过 channel 返回。

小提示:示例中的等待 5 秒用来描述洗菜和烧火的耗时。

在启动两个子协程同时去洗菜和烧水的时候,主协程就可以去干点其他事情(示例中是眯一会),等睡醒了,要做火锅的时候,就需要洗好的菜和烧好的水这两个结果了。我用下面的代码进行演示:

func main() {

   vegetablesCh := washVegetables() //洗菜

   waterCh := boilWater()           //烧水

   fmt.Println("已经安排洗菜和烧水了,我先眯一会")

   time.Sleep(2 * time.Second)

   fmt.Println("要做火锅了,看看菜和水好了吗")

   vegetables := <-vegetablesCh

   water := <-waterCh

   fmt.Println("准备好了,可以做火锅了:",vegetables,water)

}

Futures 模式下的协程和普通协程最大的区别是可以返回结果,而这个结果会在未来的某个时间点使用。所以在未来获取这个结果的操作必须是一个阻塞的操作,要一直等到获取结果为止。

如果你的大任务可以拆解为一个个独立并发执行的小任务,并且可以通过这些小任务的结果得出最终大任务的结果,就可以使用 Futures 模式。

    out <- c

  }

}

wg.Add(len(ins))

//扇入,需要启动多个goroutine用于处于多个channel中的数据

for _,cs:=range ins{

  go p(cs)

}

//等待所有输入的数据ins处理完,再关闭输出out

go func() {

  wg.Wait()

  close(out)

}()

return out

}


新增的 merge 函数的核心逻辑就是对输入的每个 channel 使用单独的协程处理,并将每个协程处理的结果都发送到变量 out 中,达到扇入的目的。总结起来就是通过多个协程并发,把多个 channel 合成一个。

在整条手机组装流水线中,merge 函数非常小,而且和业务无关,不能当作一道工序,所以我把它叫作**组件**。该 merge 组件是可以复用的,流水线中的任何工序需要扇入的时候,都可以使用 merge 组件。

> 小提示:这次的改造新增了 merge 函数,其他函数保持不变,符合开闭原则。开闭原则规定“软件中的对象(类,模块,函数等等)应该对于扩展是开放的,但是对于修改是封闭的”。

有了可以复用的 merge 组件,现在来看流水线的组织者 main 函数是如何使用扇出和扇入并发模式的,如下所示:

```go
func main() {

   coms := buy(100)    //采购100套配件

   //三班人同时组装100部手机

   phones1 := build(coms)

   phones2 := build(coms)

   phones3 := build(coms)

   //汇聚三个channel成一个

   phones := merge(phones1,phones2,phones3)

   packs := pack(phones) //打包它们以便售卖

   //输出测试,看看效果

   for p := range packs {

      fmt.Println(p)

   }

}

这个示例采购了 100 套配件,也就是开始增加产能了。于是同时调用三次 build 函数,也就是为工序 2 增加人手,这里是三班人手同时组装配件,然后通过 merge 函数这个可复用的组件将三个 channel 汇聚为一个,然后传给 pack 函数打包。

这样通过扇出和扇入模式,整条流水线就被扩充好了,大大提升了生产效率。因为已经有了通用的扇入组件 merge,所以整条流水中任何需要扇出、扇入提高性能的工序,都可以复用 merge 组件做扇入,并且不用做任何修改。

2.5.5Futures 模式

Pipeline 流水线模式中的工序是相互依赖的,上一道工序做完,下一道工序才能开始。但是在我们的实际需求中,也有大量的任务之间相互独立、没有依赖,所以为了提高性能,这些独立的任务就可以并发执行。

举个例子,比如我打算自己做顿火锅吃,那么就需要洗菜、烧水。洗菜、烧水这两个步骤相互之间没有依赖关系,是独立的,那么就可以同时做,但是最后做火锅这个步骤就需要洗好菜、烧好水之后才能进行。这个做火锅的场景就适用 Futures 模式。

Futures 模式可以理解为未来模式,主协程不用等待子协程返回的结果,可以先去做其他事情,等未来需要子协程结果的时候再来取,如果子协程还没有返回结果,就一直等待。我用下面的代码进行演示:

//洗菜

func washVegetables() <-chan string {

   vegetables := make(chan string)

   go func() {

      time.Sleep(5 * time.Second)

      vegetables <- "洗好的菜"

   }()

   return vegetables

}

//烧水

func boilWater() <-chan string {

   water := make(chan string)

   go func() {

      time.Sleep(5 * time.Second)

      water <- "烧开的水"

   }()

   return water

}

洗菜和烧水这两个相互独立的任务可以一起做,所以示例中通过开启协程的方式,实现同时做的功能。当任务完成后,结果会通过 channel 返回。

小提示:示例中的等待 5 秒用来描述洗菜和烧火的耗时。

在启动两个子协程同时去洗菜和烧水的时候,主协程就可以去干点其他事情(示例中是眯一会),等睡醒了,要做火锅的时候,就需要洗好的菜和烧好的水这两个结果了。我用下面的代码进行演示:

func main() {

   vegetablesCh := washVegetables() //洗菜

   waterCh := boilWater()           //烧水

   fmt.Println("已经安排洗菜和烧水了,我先眯一会")

   time.Sleep(2 * time.Second)

   fmt.Println("要做火锅了,看看菜和水好了吗")

   vegetables := <-vegetablesCh

   water := <-waterCh

   fmt.Println("准备好了,可以做火锅了:",vegetables,water)

}

Futures 模式下的协程和普通协程最大的区别是可以返回结果,而这个结果会在未来的某个时间点使用。所以在未来获取这个结果的操作必须是一个阻塞的操作,要一直等到获取结果为止。

如果你的大任务可以拆解为一个个独立并发执行的小任务,并且可以通过这些小任务的结果得出最终大任务的结果,就可以使用 Futures 模式。

并发模式和设计模式很相似,都是对现实场景的抽象封装,以便提供一个统一的解决方案。但和设计模式不同的是,并发模式更专注于异步和并发。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值