Go语言高级编程-第1章 语言基础1

Go语言高级编程系列是我读《Go语言高级编程》时的一些要点总结。

1.1 Go语言创世纪

  1. 本章首先简要介绍Go语言的发展历史,并较详细地分析了“Hello World”程序在各个祖先语言中演化过程。然后,对以数组、字符串和切片为代表的基础结构,对以函数、方法和接口所体现的面向过程和鸭子对象的编程,以及Go语言特有的并发编程模型和错误处理哲学做了简单介绍。

  2. Go语言很多时候被描述为“类C语言”,或者是“21世纪的C语言”。从各种角度看,Go语言确实是从C语言继承了相似的表达式语法、控制流结构、基础数据类型、调用参数传值、指针等诸多编程思想,还有彻底继承和发扬了C语言简单直接的暴力编程哲学等。图

Go语言是对C语言最彻底的一次扬弃,不仅仅是语法和C语言有着很多差异,最重要的是舍弃了C语言中灵活但是危险的指针运算。而且,Go语言还重新设计了C语言中部分不太合理运算符的优先级,并在很多细微的地方都做了必要的打磨和改变。当然,C语言中少即是多、简单直接的暴力编程哲学则被Go语言更彻底地发扬光大了(Go语言居然只有25个关键字,sepc语言规范还不到50页))。

1
2
3
4
5
6
package main
import
"fmt"
func main() {
fmt.Println( "你好, 世界!" )
}

将以上代码保存到 hello.go 文件中。因为代码中有非ASCII的中文字符,我们需要将文件的编码显式指定为无BOM的UTF8编码格式(源文件采用UTF8编码是Go语言规范所要求的)。

  1. 虽然Go语言中,函数的名字没有太多的限制,但是main包中的main函数默认是每一个可执行程序的入口。
  2. 和C语言中的字符串不同,Go语言中的字符串内容是不可变更的。在以字符串作为参数传递给fmt.Println函数时,字符串的内容并没有被复制——传递的仅仅是字符串的地址和长度(字符串的结构在 reflect.StringHeader 中定义)。在Go语言中,函数参数都是以复制的方式(不支持以引用的方式)传递(比较特殊的是,Go语言闭包函数对外部变量是以引用的方式使用)。

1.2 Hello,World的革命

  1. C语言可以说是现代IT行业最重要的软件基石,目前主流的操作系统几乎全部是由C语言开发的,许多基础系统软件也是C语言开发的。
  2. Go语言开始采用是否大小写首字母来区分符号是否可以被导出。大写字母开头表示导出的公共符号,小写字母开头表示包内部的私有符号。国内用户需要注意的是,汉字中没有大小写字母的概念,因此以汉字开头的符号目前是无法导出的(针对问题中国用户已经给出相关建议,等Go2之后或许会调整对汉字的导出规则)。

1.3 数组、字符串和切片

  1. hash表可以看作是数组和链表的混合体

  2. Go语言的数组是一种值类型,虽然数组的元素可以被修改,但是数组本身的赋值和函数传参都是以整体复制的方式处理的。Go语言字符串底层数据也是对应的字节数组,但是字符串的只读属性禁止了在程序中对底层字节数组的元素的修改。字符串赋值只是复制了数据地址和对应的长度,而不会导致底层数据的复制。切片的行为更为灵活,切片的结构和字符串结构类似,但是解除了只读限制。切片的底层数据虽然也是对应数据类型的数组,但是每个切片还有独立的长度和容量信息,切片赋值和函数传参数时也是将切片头信息部分按传值方式处理。因为切片头含有底层数据的指针,所以它的赋值也不会导致底层数据的复制。其实Go语言的赋值和函数传参规则很简单,除了闭包函数以引用的方式对外部变量访问之外,其它赋值和函数传参数都是以传值的方式处理。

  3. 因为数组的长度是数组类型的一个部分,不同长度或不同类型的数据组成的数组都是不同的类型

  4. 为了避免复制数组带来的开销,可以传递一个指向数组的指针,但是数组指针并不是数组。

1
2
3
4
5
6
7
8
9
10
11
var a = [...] int { 1 , 2 , 3 } // a 是一个数组
var b = &a
// b 是指向数组的指针
fmt.Println(a[ 0 ], a[ 1 ])
// 打印数组的前2个元素
fmt.Println(b[ 0 ], b[ 1 ])
// 通过数组指针访问数组元素的方式和
数组类似
for i, v := range b {
// 通过数组指针迭代数组的元素
fmt.Println(i, v)

其中 b 是指向 a 数组的指针,但是通过 b 访问数组中元素的写法和 a 类似的。还可以通过 for range 来迭代数组指针指向的数组元素。

  1. 用 for range 方式迭代的性能可能会更好一些,因为这种迭代可以保证不会出现数组越界的情形,每轮迭代对数组元素的访问时可以省去对下标越界的判断。

  2. 用for range方式迭代的性能可能会更好一些,因为这种迭代可以保证不会出现数组越界的情形,每轮迭代对数组元素的 访问时可以省去对下标越界的判断。

  3. 长度为0的数组在内存中并不占用空间。空数组虽然很少直接 使用,但是可以用于强调某种特有类型的操作时避免分配额外 的内存空间,比如用于管道的同步操作

  4. 我们并不关心管道中传输数据的真实类型,其中管道接收和发送操作只是用于消息的同步。对于这种场景,我们用 空数组来作为管道类型可以减少管道元素赋值时的开销。当然 一般更倾向于用无类型的匿名结构体代替:

    1
    2
    3
    4
    5
    6
    c2 := make ( chan  struct {})
    go func () {
    fmt.Println("c2")
    c2 <- struct{}{} // struct{}部分是类型, {}表示对应的结构体值
    }()
    <-c2
  5. 源代码中的文本字符串通常被解释为采用UTF8编码的Unicode码点(rune)序列。因为字节序列对应的是只读的字节序列,因此字符串可以包含任意的数据,包括byte值0。我们也可以用字符串表示GBK等非UTF8编码的数据,不过这种时候将字符串看作是一个只读的二进制数组更准确,因为 for range 等语法并不能支持非UTF8编码的字符串的遍历。

  6. 字符串虽然不是切片,但是支持切片操作,不同位置的切片底层也访问的同一块内存数据(因为字符串是只读的,相同的字符串面值常量通常是对应同一个字符串常量)

  7. Go语言的字符串中可以存放任意的二进制字节序列,而且即使是UTF8字符序列也可能会遇到坏的编码。如果遇到一个错误的UTF8编码输入,将生成一个特别的Unicode字符‘\uFFFD’,这个字符在不同的软件中的显示效果可能不太一样,在印刷中这个符号通常是一个黑色六角形或钻石形状,里面包含一个白色的问号‘ ’。下面的字符串中,我们故意损坏了第一字符的第二和第三字节,因此第一字符将会打印为“ ”,第二和第三字节则被忽略,后面的“abc”依然可以正常解码打印(错误编码不会向后扩散是UTF8编码的优秀特性之一)。

    1
    fmt.Println( "\xe4\x00\x00\xe7\x95\x8cabc" ) // 界abc
  8. Go语言除了 for range 语法对UTF8字符串提供了特殊支持外,还对字符串和 []rune 类型的相互转换提供了特殊的支持。

  9. rune 用于表示每个Unicode码点,目前只使用了21个bit位。

  10. 在将字符串转为 []byte 时,如果转换后的变量并没有被修改的情形,编译器可能会直接返回原始的字符串对应的底层数据。

    string(bytes) 转换模拟实现

    1
    2
    3
    4
    5
    6
    7
    8
    9
    func bytes2str(s [] byte ) (p string ) {
    data := make ([] byte , len (s))
    for i, c := range s {
    data[i] = c
    hdr := (*reflect.StringHeader)(unsafe.Pointer(&p))
    hdr.Data = uintptr (unsafe.Pointer(&data[ 0 ]))
    hdr.Len = len (s)
    return p
    }

    因为Go语言的字符串是只读的,无法直接同构构造底层字节数组生成字符串。

  11. 编译器可能会直接基于 []byte 底层的数据构建字符串。

    []rune(s) 转换模拟实现

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    func str2runes(s [] byte ) [] rune {
    var p [] int32
    for
    len (s) > 0 {
    r, size := utf8.DecodeRuneInString(s)
    p = append (p, r)
    s = s[size:]
    }
    return [] rune (p)
    }

    因为底层内存结构的差异,字符串到 []rune 的转换必然会导致重新分配 []rune 内存空间,然后依次解码并复制对应的Unicode码点值。这种强制转换并不存在前面提到的字符串和字节切片转化时的优化情况。

    string(runes) 转换模拟实现

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    func runes2string(s [] int32 ) string {
    var p [] byte
    buf := make ([] byte , 3 )
    for_, r := range s {
    n := utf8.EncodeRune(buf, r)
    p = append (p, buf[:n]...)
    }
    return
    string (p)
    }

    同样因为底层内存结构的差异, []rune 到字符串的转换也必然会导致重新构造字符串。这种强制转换不存在前面提到的优化情况。

  12. 切片多了一个 Cap 成员表示切片指向的内存空间的最大容量(对应元素的个数,而不是字节数)。

  13. 在开头一般都会导致内存的重新分配,而且会导致已有的元素全部复制1次。因此,从切片的开头添加元素的性能一般要比从尾部追加元素的性能差很多。

  14. 并不会复制底层的数据。对于类型,和数组的最大不同是,切片的类型和长度信息无关,只要是相同类型元素构成的切片均对应相同的切片类型。

  15. 空数组一般很少用到。但是对于切片来说, len 为 0 但是cap 容量不为 0 的切片则是非常有用的特性。当然,如果len 和 cap 都为 0 的话,则变成一个真正的空切片,虽然它并不是一个 nil 值的切片。在判断一个切片是否为空时,一般通过 len 获取切片的长度来判断,一般很少将切片和 nil 值做直接的比较。

1.4 函数、方法和接口

  1. 闭包函数是函数式编程语言的核心。
  2. Go语言程序的初始化和执行总是从 main.main 函数开始的。但是如果 main 包导入了其它的包,则会按照顺序将它们包含进 main 包里(这里的导入顺序依赖具体实现,一般可能是以文件名或包路径名的字符串顺序导入)。如果某个包被多次导入的话,在执行的时候只会导入一次。当一个包被导入时,如果它还导入了其它的包,则先将其它的包包含进来,然后创建和初始化这个包的常量和变量,再调用包里的 init 函数,如果一个包有多个 init 函数的话,调用顺序未定义(实现可能是以文件名的顺序调用),同一个文件内的多个 init则是以出现的顺序依次调用( init 不是普通函数,可以定义有多个,所以也不能被其它函数调用)。最后,当 main包的所有包级常量、变量被创建和初始化完成,并且 init函数被执行后,才会进入 main.main 函数,程序开始正常执行。

Xnip2021-12-01_21-19-26.jpg

  1. Go语言中的函数可以有多个参数和多个返回值,参数和返回值都是以传值的方式和被调用者交换数据。

  2. defer 语句在 return 语句之后修改返回值:

    1
    2
    3
    4
    func Inc() (v int ) {
    defer func (){ v++ } ()
    return 42
    }

    其中 defer 语句延迟执行了一个匿名函数,因为这个匿名函数捕获了外部函数的局部变量 v ,这种函数我们一般叫闭包。闭包对捕获的外部变量并不是传值方式访问,而是以引用的方式访问。也就是闭包操作会影响外部,外部操作会影响闭包。

  3. 下面的函数输出是3个 3

    1
    2
    3
    4
    5
    6
    func main() {
    for i := 0 ; i < 3 ; i++ {
    defer
    func (){ println (i) } ()
    }
    }

    因为是闭包,在 for 迭代语句中,每个 defer 语句延迟执行的函数引用的都是同一个 i 迭代变量,在循环结束后这个变量的值为3,因此最终输出的都是3。

  4. 虽然Go语言运行时会自动更新引用了地址变化的栈变量的指针,但最重要的一点是要明白Go语言中指针不再是固定不变的了(因此不能随意将指针保持到数值变量中,Go语言的地址也不能随意保存到不在GC控制的环境中,因此使用CGO时不能在C语言中长期持有Go语言对象的地址)。

  5. 对于有C/C++编程经验的程序员需要强调的是:不用关心Go语言中函数栈和堆的问题,编译器和运行时会帮我们搞定;同样不要假设变量在内存中的位置是固定不变的,指针随时可能会变化,特别是在你不期望它变化的时候。

  6. 面向对象编程(OOP)进入主流开发领域一般认为是从C++开始的,C++就是在兼容C语言的基础之上支持了class等面向对象的特性。然后Java编程则号称是纯粹的面向对象语言,因为Java中函数是不能独立存在的,每个函数都必然是属于某个类的。

  7. // 关闭文件
    func (f *File) Close() error {
    // ...
    }
    // 读文件数据
    func (f *File) Read( int64 offset, data [] byte ) int {
    // ...
    }
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15

    将第一个函数参数移动到函数前面,从代码角度看虽然只是一个小的改动,但是从编程哲学角度来看,Go语言已经是进入面向对象语言的行列了。我们可以给任何自定义类型添加一个或多个方法。每种类型对应的方法必须和类型的定义在同一个包中,因此是无法给 int 这类内置类型添加方法的(因为方法的定义和类型的定义不在一个包中)。

    10. 方法是由函数演变而来,只是将函数的第一个对象参数移动到了函数名前面了而已。因此我们依然可以按照原始的过程式思维来使用方法。通过叫方法表达式的特性可以将方法还原为普通类型的函数

    11. ```go
    type Cache struct {
    m map [ string ] string
    sync.Mutex
    }
    func (p *Cache) Lookup(key string ) string {
    p.Lock()
    defer p.Unlock()
    return p.m[key]
    }
    .但是在调用 p.Lock() 和p.Unlock() 时, p 并不是 Lock 和 Unlock 方法的真正接收者, 而是会将它们展开为 p.Mutex.Lock() 和 p.Mutex.Unlock()调用. 这种展开是编译期完成的, 并没有运行时代价.
  8. ```go
    type UpperWriter struct {
    io.Writer
    }
    func (p *UpperWriter) Write(data [] byte ) (n int
    , err error) {
    return p.Writer.Write(bytes.ToUpper(data))
    }
    func main() {
    fmt.Fprintln(&UpperWriter{os.Stdout},
    “hello, world” )
    }

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33

    我们可以通过定制自己的输出对象,将每个字符转为大写字符后输出

    13. 通过在结构体中嵌入匿名类型成员,可以继承匿名类型的方法。其实这个被嵌入的匿名成员不一定是普通类型,也可以是接口类型。

    ## 1.5 面向并发的内存模型

    1. 在早期,CPU都是以单核的形式顺序执行机器指令。Go语言的祖先C语言正是这种顺序编程语言的代表。顺序编程语言中的顺序是指:所有的指令都是以串行的方式执行,在相同的时刻有且仅有一个CPU在顺序执行程序的指令。

    2. 每个系统级线程都会有一个固定大小的栈(一般默认可能是2MB),这个栈主要用来保存函数递归调用时参数和局部变量。固定了栈的大小导致了两个问题:一是对于很多只需要很小的栈空间的线程来说是一个巨大的浪费,二是对于少数需要巨大栈空间的线程来说又面临栈溢出的风险。针对这两个问题的解决方案是:要么降低固定的栈大小,提升空间的利用率;要么增大栈的大小以允许更深的函数递归调用,但这两者是没法同时兼得的。相反,一个Goroutine会以一个很小的栈启动(可能是2KB或4KB),当遇到深度递归导致当前栈空间不足时,Goroutine会根据需要动态地伸缩栈的大小(主流实现中栈的最大值可达到1GB)。因为启动的代价很小,所以我们可以轻易地启动成千上万个Goroutine。

    3. Go的运行时还包含了其自己的调度器,这个调度器使用了一些技术手段,可以在n个操作系统线程上多工调度m个Goroutine。Go调度器的工作和内核的调度是相似的,但是这个调度器只关注单独的Go程序中的Goroutine。Goroutine采用的是半抢占式的协作调度,只有在当前Goroutine发生阻塞时才会导致调度;同时发生在用户态,调度器会根据具体函数只保存必要的寄存器,切换的代价要比系统线程低得多。运行时有一个 runtime.GOMAXPROCS 变量,用于控制当前运行正常非阻塞Goroutine的系统线程数目。

    4. 一般情况下,原子操作都是通过“互斥”访问来保证的,通常由特殊的CPU指令提供保护。当然,如果仅仅是想模拟下粗粒度的原子操作,我们可以借助于 sync.Mutex 来实现

    5. 用互斥锁来保护一个**数值型**的共享资源,麻烦且效率低下。**标准库的 sync/atomic 包对原子操作提供了丰富的支持。**

    6. atomic.AddUint64 函数调用保证了 total 的读取、更新和保存是一个原子操作,因此在多线程中访问也是安全的。

    7. sync/atomic 包对基本的数值类型及复杂对象的读写都提供了原子操作的支持。 atomic.Value 原子对象提供了 Load 和Store 两个原子方法,分别用于加载和保存数据,返回值和参数都是 interface{} 类型,因此可以用于任意的自定义复杂类型。

    8. ```go
    var a string
    var done bool
    func setup() {
    a = "hello, world"
    done = true
    }
    func main() {
    go setup()
    for !done {}
    print (a)
    }

我们创建了 setup 线程,用于对字符串 a 的初始化工作,初始化完成之后设置 done 标志为 true 。 main 函数所在的主线程中,通过 for !done {} 检测 done 变为 true 时,认为字符串初始化工作完成,然后进行字符串的打印工作。但是Go语言并不保证在 main 函数中观测到的对 done 的写入操作发生在对字符串 a 的写入的操作之后,因此程序很可能打印一个空字符串。更糟糕的是,因为两个线程之间没有同步事件, setup 线程对 done 的写入操作甚至无法被 main线程看到, main 函数有可能陷入死循环中。(ps 虽然我运行了几次得到的结果都是hello, world)

在Go语言中,同一个Goroutine线程内部,顺序一致性内存模型是得到保证的。但是不同的Goroutine之间,并不满足顺序一致性内存模型,需要通过明确定义的同步事件来作为同步的参考。如果两个事件不可排序,那么就说这两个事件是并发的。

因此,如果在一个Goroutine中顺序执行 a = 1; b = 2; 两个语句,虽然在当前的Goroutine中可以认为 a = 1; 语句先于 b =2; 语句执行,但是在另一个Goroutine中 b = 2; 语句可能会先于 a = 1; 语句执行,甚至在另一个Goroutine中无法看到它们的变化(可能始终在寄存器中)。

  1. 通过 sync.Mutex 互斥量也是可以实现同步的:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    func main() {
    var mu sync.Mutex
    mu.Lock()
    go
    func (){
    println ( "你好, 世界" )
    mu.Unlock()
    }()
    mu.Lock()
    }

    可以确定后台线程的 mu.Unlock() 必然在 println(“你好, 世界”) 完成后发生(同一个线程满足顺序一致性), main 函数的第二个 mu.Lock() 必然在后台线程的 mu.Unlock() 之后发生( sync.Mutex 保证),此时后台线程的打印工作已经顺利完成了

  2. 如果某个包被多次导入的话,在执行的时候只会导入一次。

  3. 要注意的是,在 main.main 函数执行之前所有代码都运行在同一个Goroutine中,也是运行在程序的主系统线程中。如果某个 init 函数内部用go关键字启动了新的Goroutine的话,新的Goroutine和 main.main 函数是并发执行的。因为所有的 init 函数和 main 函数都是在主线程完成,它们也是满足顺序一致性模型的。

  4. Channel通信是在Goroutine之间进行同步的主要方法。在无缓存的Channel上的每一次发送操作都有与其对应的接收操作相配对,发送和接收操作通常发生在不同的Goroutine上(在同一个Goroutine上执行2个操作很容易导致死锁)。

  5. 有时程序中最后一句 select{} 是一个空的管道选择语句,该语句会导致main 线程阻塞,从而避免程序过早退出。还有 for{} 、 <-make(chan int) 等诸多方法可以达到类似的效果。因为 main线程被阻塞了,如果需要程序正常退出的话可以通过调用os.Exit(0) 实现。

  6. 严谨的并发程序的正确性不应该是依赖于CPU的执行速度和休眠时间等不靠谱的因素的。严谨的并发也应该是可以静态推导出结果的:根据线程内顺序一致性,结合Channel或 sync 同步事件的可排序性来推导,最终完成各个线程各段代码的偏序关系排序。如果两个事件无法根据此规则来排序,那么它们就是并发的,也就是执行先后顺序不可靠的。解决同步问题的思路是相同的:使用显式的同步。

1.6 常见的并发模式

  1. Go语言最吸引人的地方是它内建的并发支持。

  2. 并发不是并行。并发更关注的是程序的设计层面,并发的程序完全是可以顺序执行的,只有在真正的多核CPU上才可能真正地同时运行。并行更关注的是程序的运行层面,并行一般是简单的大量重复,例如GPU中对图像处理都会有大量的并行运算。

  3. 我们可以让 main 函数保存阻塞状态不退出,只有当用户输入 Ctrl-C 时才真正退出程序

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    func main() {
    ch := make ( chan int , 64 ) // 成果队列
    go Producer( 3 , ch) // 生成 3 的倍数的序列
    go Producer( 5 , ch) // 生成 5 的倍数的序列
    go Consumer(ch)
    // 消费 生成的队列
    // Ctrl+C 退出
    sig := make ( chan os.Signal, 1 )
    signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)
    fmt.Printf( "quit (%v)\n", <-sig)
    }
  4. 在Go语言自带的godoc程序实现中有一个 vfs 的包对应虚拟的文件系统,在 vfs 包下面有一个 gatefs 的子包,gatefs 子包的目的就是为了控制访问该虚拟文件系统的最大并发数。 gatefs 包的应用很简单:

1
2
3
4
5
6
7
import (
"golang.org/x/tools/godoc/vfs"
"golang.org/x/tools/godoc/vfs/gatefs"
)
func main() {
fs := gatefs.New(vfs.OS( "/path" ), make ( chan bool, 8 )) // ...
}

其中 vfs.OS(“/path”) 基于本地文件系统构造一个虚拟的文件系统,然后 gatefs.New 基于现有的虚拟文件系统构造一个并发受控的虚拟文件系统。并发数控制的原理在前面一节已经讲过,就是通过带缓存管道的发送和接收规则来实现最大并发阻塞:

  1. Go语言中不同Goroutine之间主要依靠管道进行通信和同步。要同时处理多个管道的发送或接收操作,我们需要使用select 关键字(这个关键字和网络编程中的 select 函数的行为类似)。当 select 有多个分支时,会随机选择一个可用的管道分支,如果没有可用的管道分支则选择 default 分支,否则会一直保存阻塞状态。

  2. 通过 select 的 default 分支实现非阻塞的管道发送或接收操作:

    1
    2
    3
    4
    5
    6
    select {
    case v := <-in:
    fmt.Println(v)
    default :
    // 没有数据
    }
  3. 通过 select 来阻止 main 函数退出

    1
    2
    3
    4
    func main() {
    // do some thins
    select {}
    }
  4. 管道的发送操作和接收操作是一一对应的,如果要停止多个Goroutine那么可能需要创建同样数量的管道,这个代价太大了。其实我们可以通过 close 关闭一个管道来实现广播的效果,所有从关闭管道接收的操作均会收到一个零值和一个可选的失败标志。

  5. 在Go1.7发布时,标准库增加了一个 context 包,用来简化对于处理单个请求的多个Goroutine之间与请求域的数据、超时和退出等操作,官方有博文对此做了专门介绍。我们可以用context 包来重新实现前面的线程安全退出或超时的控制:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    func worker(ctx context.Context, wg *sync.WaitGroup) error {
    defer wg.Done()
    for {
    select {
    default:
    fmt.Println("hello")
    case <-ctx.Done():
    return ctx.Err()
    }
    }
    }
    func main() {
    ctx, cancel := context.WithTimeout(context.Background(),10*time.Second)
    var wg sync.WaitGroup
    for i := 0; i < 10; i++ {
    wg.Add(1)
    go worker(ctx, &wg)
    time.Sleep(time.Second)
    cancel()
    wg.Wait()
    }
    }

    当并发体超时或 main 主动停止工作者Goroutine时,每个工作者都可以安全退出。

  6. Go语言是带内存自动回收特性的,因此内存一般不会泄漏。

  7. 当main函数完成工作前,通过调用 cancel() 来通知后台Goroutine退出,这样就避免了Goroutine的泄漏。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    func main() {
    // 通过 Context 控制后台Goroutine状态
    ctx, cancel := context.WithCancel(context.Background())
    ch := GenerateNatural(ctx) // 自然数序列: 2, 3, 4, ...
    for i := 0 ; i < 100 ; i++ {
    prime := <-ch // 新出现的素数
    fmt.Printf( "%v: %v\n"
    , i+ 1 , prime)
    ch = PrimeFilter(ctx, ch, prime) // 基于新素数构造的过滤器
    }

Go语言高级编程-第1章 语言基础1
https://nrbackback.github.io/2021/12/04/Go语言高级编程-第1章 语言基础1/
作者
John Doe
发布于
2021年12月4日
许可协议