Java程序员学习Go指南(三)

人是否会进步以及进步得有多快,依赖的恰恰就是对自我的否定,这包括否定的深刻与否,以及否定自我的频率如何。这其实就是“不破不立”这个词表达的含义。

数组和切片的range子句遍历

切片

先看例子:

    numbers1 := []int{1, 2, 3, 4, 5, 6}
    for i := range numbers1 {
        if i == 3 {
            numbers1[i] += i
        }
    }
    fmt.Println(numbers1)

在上面这个例子中,会返回:

[1 2 3 7 5 6]

由于numbers1是⼀个 切⽚,那么迭代变量就可以有两个,右边的迭代变量代表当次迭代对应的某⼀个元素值,⽽左边的迭代变量则代表该元素值在切⽚中的索引值。

如果像上面这样只有一个迭代变量只会返回对应的索引值,这里和java很不一样,在java中使用foreach迭代只会返回元素值。

如果像如下这样又会返回什么呢:

    numbers3 := []int{1, 2, 3, 4, 5, 6}
    maxIndex3 := len(numbers2) - 1
    for i, e := range numbers3 {
        if i == maxIndex3 {
            numbers3[0] += e
        } else {
            numbers3[i+1] += e
        }
    }
    fmt.Println(numbers3)

在这个例子中,迭代返回了两个参数,i表示的是索引值,e表示的是切片中的元素。

这里打印的结果是:

[22 3 6 10 15 21]

因为切片是引用类型的,所以传入for循环中的是一个指针,所以每次遍历都会修改原来的值。

数组

    numbers2 := [...]int{1, 2, 3, 4, 5, 6}
    maxIndex2 := len(numbers2) - 1
    for i, e := range numbers2 {
        if i == maxIndex2 {
            numbers2[0] += e
        } else {
            numbers2[i+1] += e
        }
    }
    fmt.Println(numbers2)

像上面这样遍历一个数组,那么会返回什么?

[7 3 5 7 9 11]

因为数组是值类型的,所以range表达式的求值结果会被复制,也就是说,被迭代的对象是range表达式结果值的副本⽽不是原值。

在第⼀次迭代时,我改变的是numbers2的第⼆个元素的值,新值为3,也就是1和2之和。

但是,被迭代的对象的第⼆个元素却没有任何改变,毕竟它与numbers2已经是毫不相关的两个数组了。因此,在第⼆次迭代 时,我会把numbers2的第三个元素的值修改为5,即被迭代对象的第⼆个元素值2和第三个元素值3的和。

异常处理

panic

panic类似java里面的Exception,当引发一个panic的时候会将调用栈打印出来,并引发程序的崩溃终止。

panic详情会在控制权传播的过程中,被逐渐地积累和完善,并且,控制权会一级一级地沿着调用栈的反方向传播至顶端。

因此,在针对某个 goroutine 的代码执行信息中,调用栈底端的信息会先出现,然后是上一级调用的信息,以此类推,最后才是此调用栈顶端的信息。

例如下面这个例子:

main函数调用了caller1函数,而caller1函数又调用了caller2函数,那么caller2函数中代码的执行信息会先出现,然后是caller1函数中代码的执行信息,最后才是main函数的信息。

recover函数和defer语句

Go 语言的内建函数recover专用于恢复 panic,或者说平息运行时恐慌。recover函数无需任何参数,并且会返回一个空接口类型的值。

我们在使用recover 函数的时候不能将其放置在panic之后或之前。

放置之后panic 一旦发生,控制权就会讯速地沿着调用栈的反方向传播。所以,在panic函数调用之后的代码,根本就没有执行的机会;把调用recover函数的代码提前,那时未发生 panic,那么该函数就不会做任何事情,并且只会返回一个nil。

所以我们需要将recover函数和defer语句配合使用,defer语句相当于java里面的try -finally,它会让其携带的defer函数的调用延迟执行,并且会延迟到该defer语句所属的函数即将结束执行的那一刻。

利用recover和defer防止由panic引起的程序崩溃

func main() {
    fmt.Println("Enter function main.")
    defer func(){
        fmt.Println("Enter defer function.")
        if p := recover(); p != nil {
            fmt.Printf("panic: %s\n", p)
        }
        fmt.Println("Exit defer function.")
    }()
    // 引发panic。
    panic(errors.New("something wrong"))
    fmt.Println("Exit function main.")
}

在上面这个代码中会返回:

Enter function main.
Enter defer function.
panic: something wrong
Exit defer function.

在defer语句中,通过recover函数将错误给成功捕获,避免了程序的崩溃。

测试

  • 对于功能测试函数来说,其名称必须以Test为前缀,并且参数列表中只应有一个*testing.T类型的参数声明。
  • 对于性能测试函数来说,其名称必须以Benchmark为前缀,并且唯一参数的类型必须是*testing.B类型的。

功能测试

$ go test lesson2/article10/main
ok      lesson2/article10/main  0.006s 

如上,我们执行一个功能测试,会返回一个ok,表示测试成功,并且会显示在0.006s完成了测试。

如果我们再次的执行

$ go test lesson2/article10/main
ok      lesson2/article10/main  (cached) 

会发现多了个(cached),这表明我们的测试结果被缓存了,但是如果我们的程序有任何变动,缓存数据就会失效,go 命令就会再次真正地执行操作。所以我们并不用担心打印出的缓存数据不是实时的结果。

go 命令会定期地删除最近未使用的缓存数据,但是,如果你想手动删除所有的缓存数据,运行一下go clean -cache命令就好了。

对于测试成功的结果,go 命令也是会缓存的。运行go clean -testcache将会删除所有的测试结果缓存。不过,这样做肯定不会删除任何构建结果缓存。

性能测试

性能测试这个在java中是没有中,只能依靠其他的手段实现。所以我们来看看Go中性能测试是如何做的。

我们写一个测试函数:

func BenchmarkGetPrimes(b *testing.B) {
    for i := 0; i < b.N; i++ {
        GetPrimes(1000)
    }
}

在这个函数中调用了GetPrimes方法,并传入一个1000的值。

我们再运行上面的测试函数如下:

$ go test -bench=. -run=^$  lesson2/article10/main
goos: darwin
goarch: amd64
pkg: lesson2/article10/main
BenchmarkGetPrimes-8      312973              3307 ns/op
PASS
ok      lesson2/article10/main  1.082s

我们在上面的命令中加入了两个参数:

-bench=.,只有有了这个标记,命令才会进行性能测试,.表示需要执行任意名称的性能测试函数。
-run=^$,这个标记用于表明需要执行哪些功能测试函数,值^$意味着:只执行名称为空的功能测试函数,换句话说,不执行任何功能测试函数。

在运行完命令后的结果中:

BenchmarkGetPrimes-8被称为单个性能测试的名称,它表示命令执行了性能测试函数BenchmarkGetPrimes,并且当时所用的最大 P 数量为8。最大 P 数量相当于可以同时运行 goroutine 的逻辑 CPU 的最大个数。

在写测试函数的时候会调用传入参数b *testing.B的b.N。go test命令会先尝试把b.N设置为1,然后执行测试函数。

如果测试函数的执行时间没有超过上限,此上限默认为 1 秒,那么命令就会改大b.N的值,然后再次执行测试函数,如此往复,直到这个时间大于或等于上限为止。

所以b.N就是指的上面结果中的312973。3307 ns/op表明单次执行GetPrimes函数的平均耗时为3307纳秒。

放上一个示例图看一下:

条件变量sync.Cond

sync.Cond类似于java里面的Condition,初始化的时候也是需要互斥锁初始化。

条件变量提供的方法有三个:等待通知(wait)、单发通知(signal)和广播通知(broadcast)。

等待通知的时候需要在互斥锁保护下进行,但是在signal和broadcast却是需要在对应的互斥锁解锁之后再做这两种操作。

sync.Cond类型并不是开箱即用的。我们只能利用sync.NewCond函数创建它的指针值。这个函数需要一个sync.Locker类型的参数值。

因此我们通常需要这样进行初始化:

    var lock sync.Mutex 
    cond := sync.NewCond(&lock)

在为sendCond变量做初始化的时候,把基于lock变量的指针值传给了sync.NewCond函数。

我们下面来看一个例子,比较长,需要点耐心:

func main() {
    // mailbox 代表信箱。
    // 0代表信箱是空的,1代表信箱是满的。
    var mailbox uint8
    // lock 代表信箱上的锁。
    var lock sync.Mutex
    // sendCond 代表专用于发信的条件变量。
    sendCond := sync.NewCond(&lock)
    // recvCond 代表专用于收信的条件变量。
    recvCond := sync.NewCond(&lock)

    // send 代表用于发信的函数。
    send := func(id, index int) {
        lock.Lock()
        for mailbox == 1 {
            sendCond.Wait()
        }
        log.Printf("sender [%d-%d]: the mailbox is empty.",
            id, index)
        mailbox = 1
        log.Printf("sender [%d-%d]: the letter has been sent.",
            id, index)
        lock.Unlock()
        recvCond.Broadcast()
    }

    // recv 代表用于收信的函数。
    recv := func(id, index int) {
        lock.Lock()
        for mailbox == 0 {
            recvCond.Wait()
        }
        log.Printf("receiver [%d-%d]: the mailbox is full.",
            id, index)
        mailbox = 0
        log.Printf("receiver [%d-%d]: the letter has been received.",
            id, index)
        lock.Unlock()
        sendCond.Signal() // 确定只会有一个发信的goroutine。
    }

    // sign 用于传递演示完成的信号。
    sign := make(chan struct{}, 3)
    max := 6
    go func(id, max int) { // 用于发信。
        defer func() {
            sign <- struct{}{}
        }()
        for i := 1; i <= max; i++ {
            time.Sleep(time.Millisecond * 500)
            send(id, i)
        }
    }(0, max)
    go func(id, max int) { // 用于收信。
        defer func() {
            sign <- struct{}{}
        }()
        for j := 1; j <= max; j++ {
            time.Sleep(time.Millisecond * 200)
            recv(id, j)
        }
    }(1, max/2)
    go func(id, max int) { // 用于收信。
        defer func() {
            sign <- struct{}{}
        }()
        for k := 1; k <= max; k++ {
            time.Sleep(time.Millisecond * 200)
            recv(id, k)
        }
    }(2, max/2)

    <-sign
    <-sign
    <-sign
}

在上面的例子中,使用了两个condition,sendCond和recvCond。send函数用于发信的函数,因为我在这里收信方设置了两个goroutine,所以在发送方使用了Broadcast,而接收方使用了Signal。

看完了上面的代码,在看看下面的几个注意事项

先加锁再调用Wait

我们下面来看看Wait方法做了什么:

  1. 把调用它的 goroutine(也就是当前的 goroutine)加入到当前条件变量的通知队列中。
  2. 解锁当前的条件变量基于的那个互斥锁。
  3. 让当前的 goroutine 处于等待状态,等到通知到来时再决定是否唤醒它。此时,这个 goroutine 就会阻塞在调用这个Wait方法的那行代码上。
  4. 如果通知到来并且决定唤醒这个 goroutine,那么就在唤醒它之后重新锁定当前条件变量基于的互斥锁。自此之后,当前的 goroutine 就会继续执行后面的代码了。

所以Wait方法实际上经历了:
入队 -> 解锁 -> 等待 -> 唤醒 -> 重新加锁 -> 继续执行

所以在阻塞当前的goroutine之前会解锁,所以如果不先进行锁定的话会引发一个不可恢复的 panic。

如果当前的 goroutine 无法解锁,别的 goroutine 也都不来解锁,那么又由谁来进入临界区,并改变共享资源的状态呢?

要用for语句来包裹调用其Wait方法的表达式

这主要是为了保险起见。如果一个 goroutine 因收到通知而被唤醒,但却发现共享资源的状态,依然不符合它的要求,那么就应该再次调用条件变量的Wait方法,并继续等待下次通知的到来。

还有就是为了防止:

  1. 有多个 goroutine 在等待共享资源的同一种状态。被唤醒的时候无法保证每次的共享资源的状态都是正确的,所以需要做一个检查
  2. 共享资源可能有的状态可能有很多。所以,在设计合理的前提下,单一的结果一定不可能满足所有 goroutine 的条件。那些未被满足的 goroutine 显然还需要继续等待和检查。
  3. 在一些多 CPU 核心的计算机系统中,即使没有收到条件变量的通知,调用其Wait方法的 goroutine 也是有可能被唤醒的。这是由计算机硬件层面决定的。

Signal方法和Broadcast方法

条件变量的Wait方法总会把当前的 goroutine 添加到通知队列的队尾,而它的Signal方法总会从通知队列的队首开始,查找可被唤醒的 goroutine。所以,因Signal方法的通知,而被唤醒的 goroutine 一般都是最早等待的那一个。

所以如果能确定只有一个goroutine在等待通知,或者只需要唤醒任意一个goroutine就可以满足要求,那么可以使用Signal,其他情况最好使用Broadcast

sync.WaitGroup

WaitGroup其实有点像java的CountDownLatch,可以用来控制goroutine之间协作流程。

WaitGroup类型拥有三个指针方法:Add、Done和Wait。

例如:

func coordinateWithWaitGroup() {
    var wg sync.WaitGroup
    wg.Add(2)
    num := int32(0)
    fmt.Printf("The number: %d [with sync.WaitGroup]\n", num)
    max := int32(10)
    go addNum(&num, 3, max, wg.Done)
    go addNum(&num, 4, max, wg.Done)
    wg.Wait()
}

// addNum 用于原子地增加numP所指的变量的值。
func addNum(numP *int32, id, max int32, deferFunc func()) {
    defer func() {
        deferFunc()
    }()
    for i := 0; ; i++ {
        currNum := atomic.LoadInt32(numP)
        if currNum >= max {
            break
        }
        newNum := currNum + 2
        time.Sleep(time.Millisecond * 200)
        if atomic.CompareAndSwapInt32(numP, currNum, newNum) {
            fmt.Printf("The number: %d [%d-%d]\n", newNum, id, i)
        } else {
            fmt.Printf("The CAS operation failed. [%d-%d]\n", id, i)
        }
    }
}

coordinateWithWaitGroup函数会等待两个addNum函数运行完wg.Done方法后wg.Wait()才会继续放行。

由于主goroutine需要等待其他的goroutine运行完毕后才能继续往下执行,所以可以在addNum的defer函数中才调用wg.Done函数。

需要注意的是,和java的CountDownLatch不一样的是CountDownLatch的countDown方法是可以重复调用,即使计数小于零也不会报错,但是WaitGroup的计数器的值不能小于0,是因为这样会引发一个 panic。

sync.Once

该方法的功能是只执行“首次被调用时传入的”那个函数,并且之后不会再执行任何参数函数。

举例:

    var counter uint32
    var once sync.Once
    once.Do(func() {
        atomic.AddUint32(&counter, 2)
    })
    fmt.Printf("The counter: %d\n", counter)
    once.Do(func() {
        atomic.AddUint32(&counter, 1)
    })

    fmt.Printf("The counter: %d\n", counter)

在上面这个例子中,once.Do里面修饰的函数只会执行第一个,第二个函数并不会得到执行。

因为,Once类型中有一个名叫done的uint32类型的字段。它的作用是记录其所属值的Do方法被调用的次数。不过,该字段的值只可能是0或者1。一旦Do方法的首次调用完成,它的值就会从0变为1。

所以每次在执行once.Do的时候都会判断done是不是为1,并且在这个条件判断之后,Do方法会立即锁定其所属值中的那个sync.Mutex类型的字段m。然后,它会在临界区中再次检查done字段的值,并且仅在条件满足时,才会去调用参数函数,以及用原子操作把done的值变为1。

以上保证了sync.Once只会调用一次。

需要注意的是:

  1. 由于Do方法里面加了互斥锁,所以如果有多个goroutine同时执行,并且里面参数函数需要执行很长时间,那么其他goroutine会一起阻塞
  2. Do方法在参数函数执行结束后,对done字段的赋值用的是原子操作,并且,这一操作是被挂在defer语句中的。因此,不论参数函数的执行即使报错,done字段的值都会变为1。