Go-高级语法特性

defer语句

如果一条语句前面加了一个defer, 那么这个defer语句会在当前作用域内的所有语句执行完之后再执行, 如果有多条defer语句, 那么在后面的defer语句先执行(类似于栈).

例如:

package main

import "fmt"

func main() {

    a, b := 1, 2
    defer fmt.Println("heihei")
    defer fmt.Println("haha")
    fmt.Println(a + b)
}

那么输出的顺序是:

3
haha
heihei

涉及变量的defer语句

考虑下面的语句:

package main

import "fmt"

func main() {

    a, b := 1, 2
    c := a + b
    defer fmt.Println("first", c) //3
    c = 4
    defer fmt.Println("second", c) //4
    c = 5
    defer func() {
        fmt.Println("third", c) //注意如果打印的话, 这里c是6而不是5

    }()
    c = 6
    fmt.Println(a + b)
}

打印的结果是:

6
third 6
second 4
first 3

可以发现:

  • 如果defer后面跟的是语句, 那么语句中涉及的变量会被保存一个副本, 和之后该变量的值的变化无关.
  • 如果defer后面跟的是匿名函数的调用, 那么这个匿名函数函数体内的变量不会被保存副本, 只有在defer执行时才会进行计算.

时间相关的函数

时间标准库的导入是:

import "time"

获取当前时间

t := time.Now()

其中, ttime.Time数据类型

  • 时间戳: t.Unix()

以毫秒为单位统计运行时间

t0 := time.Now()

/* 要统计的代码 */

t1 := time.Now()

fmt.Println(t1.Sub(t0).Milliseconds())

休眠函数

以毫秒为单位进行休眠:

// 休眠50毫秒
time.Sleep(50 * time.Millisecond)

MPG并发模型

假设一个机器中有$n$个CPU, 那么这个机器就有$n$个进程.

  • M: 全称是Machine, 是在用户态对于CPU的一种抽象, 一个进程对应一个Machine, 用于真正执行Goroutine.
  • P: 全称是Processor, 可以理解为存储Goroutine的一个队列.
  • G: 全称是Goroutine, 也叫协程, 是用户代码的运行时的状态.

协程在放到Processor之前, 会被放到一个协程的全局队列runqueue中.

  1. 当创建第一个协程时, 会发生:
  • 系统创建一个Machine, 并绑定到一个进程上.
  • 系统创建一个Processor, 并绑定到当前的Machine上.
  • 协程Goroutine会被放到Processor上, 然后开始在Machine执行.
  1. 如果协程数量很多时, 会发生:
  • 系统会创建更多的Machine, 绑定到对应的进程上.
  • 系统会创建更多的Processor, 用来存储将要运行的Goroutine.
  1. 如果Machine在执行一个Goroutine时, 发生了I/O这种高耗时任务或系统调用, 会发生:
  • 绑定到这个Machine的Processor会被切换到其他的Machine.
  • 这个Machine仍在处理发生I/O的Goroutine, 直到结束.
  • 结束后, 这个Machine会处于短暂的空闲状态

协程

Go语言中, 创建协程的办法是在函数调用之前加上一个go, 例如:

package main

import "fmt"

func foo() {
  fmt.Println("Hello")
}

func main() {
  // 创建了一个运行foo函数的协程
  go foo()
}
注意, 使用`go`创建的所有协程, 它们并不存在父子关系, 在调度层面是并列关系.

在Go语言中, 有一个主协程(也叫main协程), 主协程和其他协程的关系是:

  • 主协程结束会导致所有其他协程强制结束.
  • 其他协程如果出现panic, 会导致主协程也强制结束, 这个需要避免.

主协程等待子协程

如果要让主协程等待所有子协程完成之后再结束, 可以使用sync.WaitGroup{}.

具体例子如下:

package main

import "sync"

func main() {

  wg := sync.WaitGroup{}

  /* 这里的2是main协程需要等待的子协程的个数 */
  wg.Add(2)

  /* 创建两个子协程, 然后子协程运行结束后执行Done */
  go func() {
    defer wg.Done()
    fmt.Println("...")
  }()

  go func() {
    defer wg.Done()
    fmt.Println("....")
  }()

  /* main协程等待子协程 */
  wg.Wait()
}

这个WaitGroup可以写很多个, 最终的效果等于所有写的WaitGroup拼接起来.

子协程故障不导致主协程挂

如果要子协程发生panic后不让主协程挂掉, 只需要在可能会发生panic的子协程的函数最前面加上这句话:

func f1() {

  /* 如果发生了panic, 那么最终还会执行这个defer */
  defer func() {

    err := recover()
    /* 如果err不是nil, 证明发生了panic, 后面是panic的处理逻辑 */
    if err != nil {
      fmt.Println(err)
    }

  }

}

临界区和锁

临界区(Critical Section): 临界区是指一个协程中的一段代码, 这段代码中涉及修改全局变量的部分.

需要保证: 同一时间段内, 有且仅有一个协程能够进入它的临界区.

实现这个原则可以通过两种方式:

  • 对临界资源加锁, 可以使用互斥锁mutex, 在Go语言中是sync.Mutex.

    ```go
    import “sync”

    / mutex锁是全局变量 /
    var lock = sync.Mutex{}

    func f1() {

lock.lock()
/* 临界区代码 */
lock.unlock()

}


* 将临界区代码用等价的原子操作来实现, 原子操作保证一个协程执行临界区代码时不会被切换, 可以使用Go语言中的`sync/atomic`包.



## Channel

Channel的本质是一个固定长度的队列, 它的类型关键词是`chan type`, 其中`type`可以是`int`或者`struct{}`这种数据类型.

* Channel可以使用`make`创建:

```go
// 创建一个管道, 里面元素是bool类型, 大小是3
ch := make(chan bool, 3)
  • 写Channel, 可以使用: ch<-你要写的元素

    • 注意: 当你向Channel中写入元素后, 如果这个Channel原始为空, 那么它会唤醒所有等待这个Channel读取的协程, 相当于内置实现了生产者-消费者算法.
  • 读Channel, 可以使用<-ch.

    • 注意1: <-ch这个整体是一个表达式, 这个整体就代表从Channel中读取的队头元素.

    • 注意2: 此时需要处理异常:

      a, ok := <-ch
      // 当且仅当ch被close, 并且ch为空时, ok才是false, 此时需要处理异常
      
    • 注意3: 当使用<-ch后, 如果ch为空, 那么该协程会被阻塞, 直到有协程写入.

  • 关闭Channel可以使用: close(ch), 注意: 关闭Channel仅代表不可以向Channel中写入元素, 但是还可以读取.

使用Channel实现主协程等待子协程

借助Channel可以阻塞协程的特性, 可以实现主协程等待一个子协程:

func main() {

  /* 1. 创建一个没有buffer的Channel */
  ch := make(chan struct{}, 0)

  /* 一个子协程 */
  go func() {
    /* 协程代码 */

    /* 3. 需要被等待的子协程再结束后写入Channel, 唤醒main协程 */
    defer ch<-struct{}{}
  }

  /* 2. 读取Channel, 由于Channel容量为0, main协程会被阻塞 */
  <-ch
}

为什么Channel的数据类型是struct{} ?

因为struct{}这种类型在创建实例struct{}{}时, 并不实际占用内存.

什么是没有Buffer的Channel

如果使用ch := make(chan struct{}, 0)或者ch := make(chan struct{})创建一个Channel, 那么这个Channel就是没有Buffer的, 此时, 向这个Channel中读/写元素都会导致Channel被阻塞.

因此, 借助这个思路, 对于每一个协程之间的先后关系, 都可以通过一个容量为0的Channel进行实现.

死锁

例子1

package main

import (
    "fmt"
)

func main() {

    ch := make(chan struct{}, 0)

    go func() {
        /* 向没有Buffer的Channel写入会阻塞协程 */
        ch <- struct{}{}
        fmt.Println("Hello")
    }()
    /* main协程也会被阻塞 */
    ch <- struct{}{}
}

在这个例子中, 子协程和main协程都会被永远阻塞, go语言会爆出deadlock的运行时异常.

例子2

package main

import (
    "fmt"
    "time"
)

func main() {

    ch := make(chan struct{}, 0)

    go func() {
        /* 向没有Buffer的Channel写入会阻塞协程 */
        ch <- struct{}{}
        fmt.Println("Hello")
    }()

    /* 增加一个子协程 */
    go func() {
        time.Sleep(1 * time.Hour)
    }()

    /* main协程也会被阻塞 */
    ch <- struct{}{}
}

这个例子在上面的基础上增加了一个休眠1小时的子协程, 运行这个代码, go语言居然就不会爆deadlock的Error了.

这是因为: 增加一个子协程后, 在运行时子协程休眠了, 但是Go语言不知道它后面会执行什么代码, 有可能要执行的代码会帮助解除main协程或前面协程的阻塞, 因此是有希望不出现deadlock的, 所以go语言没有爆deadlock.

因此:

如果Go代码没有爆deadlock, 不代表它真的没有deadlock, Go语言会假设现在还没有结束的协程可能会帮助解决deadlock, 因此要注意代码逻辑.