简介
本文为Go学习过程中记录的笔记,参考文档如下:《Go入门指南》
错误处理与测试
1. 定义错误:
Go有一个预先定义的error接口类型:
type error interface {
Error() string
}
通过调用Error()方法我们可以获取其错误信息。
-
简单的错误可以用以下的方式定义然后在函数中最终返回:
err := errors.New("errmsg")
-
也可以自定义错误类型,以文件错误为例:
type PathError struct { Op string // "open", "unlink", etc. Path string // The associated file. Err error // Returned by the system call. } func (e *PathError) String() string { return e.Op + " " + e.Path + ": "+ e.Err.Error() }
通过为自定义类型添加
String()
方法,实现自定义类型错误信息的正确打印。如果有不同错误条件可能发生,那么对实际的错误使用类型断言或类型判断(type-switch)是很有用的,并且可以根据错误场景做一些补救和恢复操作。
if e, ok := err.(*os.PathError); ok { // remedy situation }
-
用fmt创建错误对象:
如果想要返回包含错误参数的更有信息量的错误信息,可以用
fmt.Errorf()
实现。它和 fmt.Printf () 完全一样,接收有一个或多个格式占位符的格式化字符串和相应数量的占位变量。和打印信息不同的是它用信息生成错误对象。if len(os.Args) > 1 && (os.Args[1] == "-h" || os.Args[1] == "--help") { err = fmt.Errorf("usage: %s infile.txt outfile.txt", filepath.Base(os.Args[0])) return }
在自定义错误类型的时候,以 “Error” 结尾,错误变量使用 “err” 或 “Err” 开头,编程过程中尽量遵守这样的命名规范。
2. 运行时异常和panic:
当发生像数组下标越界或类型断言失败这样的运行错误时,Go 运行时会触发运行时 panic,伴随着程序的崩溃抛出一个 runtime.Error
接口类型的值。这个错误值有个 RuntimeError()
方法用于区别普通错误。
panic()
可以将当前程序运行中止,同时输出错误信息。如果是在多层函数嵌套调用过程中发生了panic()
,会立即中止当前函数的执行,一层一层往上冒泡,同时保证每一层的defer语句的执行,这个过程称为panicking。
3. 从panic中恢复(Recover):
正如名字一样,这个(recover)内建函数被用于从 panic 或 错误场景中恢复:让程序可以从 panicking 重新获得控制权,停止终止过程进而恢复正常执行。
因为发生了panic()
之后只会保证defer语句的执行,因此recover()
只能被defer修饰,如果是正常执行代码,调用recover()
会返回nil,且没有其他效果。
如果在多层函数嵌套过程中使用了defer()
搭配recover()
做故障恢复,那么发生了panic()
的函数中,做恢复处理的函数以及其下层的函数无法正常执行,其上层的所有函数都可以正常执行。
// panic_recover.go
package main
import (
"fmt"
)
func badCall() {
panic("bad end")
}
func test() {
defer func() {
if e := recover(); e != nil {
fmt.Printf("Panicing %s\r\n", e)
}
}()
badCall()
fmt.Printf("After bad call\r\n") // <-- wordt niet bereikt
}
func main() {
fmt.Printf("Calling test\r\n")
test()
fmt.Printf("Test completed\r\n")
}
Calling test
Panicing bad end
Test completed
4. 自定义包中的错误处理和panicking:
1) 在包内部,如果发生了panic()
,总是应该恢复,不允许panic()
扩散到外部;
2) 向包的调用者返回错误值,而不是panic
即,panic()
只能在包的外部不可访问方法中存在,任何外部可访问的方法中都不应该有panic()
。
5. 一种用闭包处理错误的模式:
每当函数返回时,我们应该检查是否有错误发生:但是这会导致重复乏味的代码。结合 defer/panic/recover 机制和闭包可以得到一个我们马上要讨论的更加优雅的模式。不过这个模式只有当所有的函数都是同一种签名时可用,这样就有相当大的限制。
一个很好的使用它的例子是 web 应用,所有的处理函数都是下面这样:
func handler1(w http.ResponseWriter, r *http.Request) { ... }
为了很好地实现这种模式,我们给这个类型的函数签名一个名字:
fType1 = func f(a type1, b type2)
此外还需要两个帮助函数:
1) check:用来检查是否有错误和panic发生的函数;
func check(err error) { if err != nil { panic(err) } }
2) errorhandler:这是一个包装函数,接受一个fType1类型的函数fn并返回一个调用fn的函数,里面包含了defer/cover机制:
func errorHandler(fn fType1) fType1 {
return func(a type1, b type2) {
defer func() {
if err, ok := recover().(error); ok {
log.Printf(“run time panic: %v”, err)
}
}()
fn(a, b)
}
}
对于任意类型函数如下,都可以使用errorHandler(fn fType1)
做包装得到返回函数,再调用实现安全调用。
func f1(a type1, b type2) {
...
f, _, err := // call function/method
check(err)
t, err := // call function/method
check(err)
_, err2 := // call function/method
check(err2)
...
}
6. 启动外部命令和程序:
有两种方式,第一种是使用os包的StartProcess()
函数,它可以调用或启动外部系统命令和二进制可执行文件,其中有三个参数,第一个参数是要运行的进程,第二个参数用来传递选项或参数,第三个参数是含有系统环境基本信息的结构体。
// exec.go
package main
import (
"fmt"
"os/exec"
"os"
)
func main() {
// 获取当前系统环境
env := os.Environ()
procAttr := &os.ProcAttr{
Env: env,
// 可操作文件句柄
Files: []*os.File{
os.Stdin,
os.Stdout,
os.Stderr,
},
}
// 执行/bin/ls文件,参数为列表为:ls,-l,环境为procAttr
pid, err := os.StartProcess("/bin/ls", []string{"ls", "-l"}, procAttr)
if err != nil {
fmt.Printf("Error %v starting process!", err) //
os.Exit(1)
}
fmt.Printf("The process id is %v", pid)
exec包中也有同样功能的更简单的结构体和函数,常用的函数有:exec.Command(name string, arg ...string)
和Run()
。首先需要用系统命令或可执行文件的名字创建一个Command对象,然后用这个对象作为接收者调用Run()
。
// cmd := exec.Command("ls", "-l") // no error, but doesn't show anything ?
cmd := exec.Command("gedit") // this opens a gedit-window
err = cmd.Run()
if err != nil {
fmt.Printf("Error %v executing command!", err)
os.Exit(1)
}
fmt.Printf("The command is %v", cmd)
}
7. Go中的单元测试和基准测试:
-
单元测试:
在Go中,名为testing的包被专用用来进行自动化测试,日志和错误报告,并且还包含一些基准测试函数的功能。测试需要遵循一定的规则:
-
我们通过编写一些Go源文件来测试代码,测试程序必须属于被测试的包,并且文件名满足这种形式
*_test.go
,所以测试代码和包中的业务代码是分开的。(_test
程序不会被普通的 Go 编译器编译,只有 gotest 会编译所有的程序) -
测试文件中必须导入 “testing” 包,并写一些名字以
TestZzz
打头的全局函数,这里的Zzz
是被测试函数的字母描述,如TestFmtInterface,TestPayEmployees等。 -
测试函数的参数必须为
t *testing.T
,T是传给测试函数的结构类型,用来管理测试状态,如t.Log,t.Error,t.ErrorF 等。在函数的结尾把输出跟想要的结果对比,如果不等就打印一个错误。成功的测试则直接返回。 -
可以通过以下函数来通知测试失败:
func (t *T) Fail()
:标记测试函数为失败,然后继续执行剩下的测试;func (t *T) FailNow()
:标记测试函数为失败并中止执行;文件中别的测试也被略过,继续执行下一个文件;func (t *T) Log(args ...interface{})
:args 被用默认的格式格式化并打印到错误日志中。func (t *T) Fatal(args ...interface{})
:先执行Log()
的效果再执行FailNow()
的效果。
-
运行go test来编译测试程序,并执行程序中所有的
TestZzz
函数,函数通过则会打印相应的PASS。go test fmt_test.go --chatty === RUN fmt.TestFlagParser --- PASS: fmt.TestFlagParser === RUN fmt.TestArrayPrinter --- PASS: fmt.TestArrayPrinter ...
-
-
基准测试:
testing包中有一些类型和函数可以用来做简单的基准测试,测试代码中必须包含以
BenchmarkZzz
开头的函数并接收一个*testing.B
类型的参数,比如:func BenchmarkReverse(b *testing.B) { ... }
执行基准测试时,需要添加-bench参数。
8. 测试案例:
// even/even.go
package even
func Even(i int) bool { // Exported function
return i%2 == 0
}
func Odd(i int) bool { // Exported function
return i%2 != 0
}
// even/oddeven_test.go
package even
import "testing"
func TestEven(t *testing.T) {
if !Even(10) {
t.Log(" 10 must be even!")
t.Fail()
}
if Even(7) {
t.Log(" 7 is not even!")
t.Fail()
}
}
func TestOdd(t *testing.T) {
if !Odd(11) {
t.Log(" 11 must be odd!")
t.Fail()
}
if Odd(10) {
t.Log(" 10 is not odd!")
t.Fail()
}
}
// linux系统下调用命令
go test -v
[root@iZwz95oq74pfx6kju2ptxlZ even]# go test -v
=== RUN TestEven
--- PASS: TestEven (0.00s)
=== RUN TestOdd
--- PASS: TestOdd (0.00s)
PASS
ok even 0.003s
由于测试需要具体的输入用例且不可能测试到所有的用例,因此我们使用的用例应该包含以下情况:
- 正常的用例;
- 反面的用例;
- 边界检查用例。
我们也可以将需要测试数据以及测试结果放置在表中,通过for循环实现遍历输入以及输出比较,达到测试的目的。
协程与通道
不要通过共享内存来通信,而通过通信来共享内存。
协程的设计隐藏了许多线程创建和管理方面的复杂工作,协程是轻量的,比线程更轻。使用4K的内存就可以在堆中创建它们?栈的管理是自动的,但不是由垃圾回收器管理的,而是在协程退出后自动释放。
存在两种并发方式:确定性的(明确定义的排序)和非确定性的(加锁/互斥从而未定义排序),Go的协程和通道理所当然地支持确定性的并发方式。协程是通过使用关键字go
调用一个函数或者方法来实现的。这样会在当前的计算过程中开始一个同时进行的函数,在相同的地址空间中分配独立的栈。
协程的栈会根据需要进行伸缩,不会出现栈溢出;开发者无需关心栈的大小。当协程结束的时候,它会静默退出,用来启动这个协程的函数也不会得到任何的返回值。
在gc
编译器下,必须设置GOMAXPROCS
为一个大于默认值1的数值来允许运行时支持使用多于1个的操作系统线程,否则所有的协程都会共享同一个线程。
当GOMAXPROCS
大于1时,会有一个线程池管理多线程。gccgo编译器会使得GOMAXPROCS
与运行中的协程数量相等。假设一个机器上有n
个处理器或者核心,设置环境变量GOMAXPROCS
>=n,那么协程会被分割到这n个处理器上。如果只有一个写成正在执行,不要设置GOMAXPROCS
。
通过函数runtime.GOMAXPROCS(n)
可以代码的方式设置GOMAXPROCS
。
为什么通过通道可以避免使用锁?
对一个通道读数据和写数据的整个过程是原子性的
1. Go协程(goroutines)与协程(coroutines)
在其他语言中,也都有协程的概念,但是Go协程与协程之间存在着一定的区别:
- Go协程意味着支持并行(或者可以以并行的方式部署),协程一般来说不是这样的;
- Go协程通过通道来通信,协程通过让出和恢复操作来通信。
2. 协程间的信道:
Go有一个特殊类型:channel
,像是通道,可以通过它们发送类型化的数据在协程之间通信,数据通过通道,同一时间只有一个协程可以访问数据,所以不会出现数据竞争。
一个通道只能传输一种类型的数据,比如:chan int
或者chan string
,所有的类型都可以用于通道,空接口interface{}
也可以,甚至可以创建通道的通道。
var ch chan string
ch = make(chan string)
//也可以直接用以下的代码创建
ch := make(chan string)
-
通信操作符
<-
:ch <- int1
表示用通道ch发送变量int1,int2 = <- ch
表示变量从通道中取值并赋给变量int2,如果通道中没有值则会阻塞当前协程。<- ch
可以单独调用获取通道的值,当前值会被丢弃,但是可以用来验证,所以以下代码是合法的:if <- ch != 1000{ ... }
package main import ( "fmt" "time" ) func main() { ch := make(chan string) go sendData(ch) go getData(ch) time.Sleep(1e9) } func sendData(ch chan string) { ch <- "Washington" ch <- "Tripoli" ch <- "London" ch <- "Beijing" ch <- "Tokio" } func getData(ch chan string) { var input string // time.Sleep(2e9) for { input = <-ch fmt.Printf("%s ", input) } }
- main () 等待了 1 秒让两个协程完成,如果不这样,sendData () 就没有机会输出;
- getData () 使用了无限循环:它随着 sendData () 的发送完成和 ch 变空也结束了;
- 如果我们移除一个或所有
go
关键字,程序无法运行,Go 运行时会抛出 panic:- 移除第一个的
go
关键字,因为通道默认大小是1,所以在执行完ch <- "Washington"
之后函数就会一直阻塞,无法退出也就无法执行getData()
造成死锁; - 移除第二个的
go
关键字,此时getData()
作为函数被调用,而尽管通道中的数据可以正常读取,但是存在for循环且是死循环永远不会退出,程序抛出panic; - 移除两个
go
关键字,则会导致第一种情况的发生。
- 移除第一个的
-
通道阻塞:
通道在默认情况下的大小只有1,即通道默认情况下只能接收1个消息,发送者无法在通道非空的情况下继续发送消息,相关代码会被阻塞;接收者也无法在通道已空的情况下接收消息,相关代码会被阻塞。
以下程序会导致panic,所有的协程都休眠了,导致死锁。原因是有缓冲通道和无缓冲通道之间存在一定的区别,有缓冲的通道,即使通道容量设置为1,在填满通道之后,只要后续代码不含有往通道中填充信息的部分就可以继续运行,而无缓冲的通道则不同,无缓冲的通道在填满通道之后,就无法继续执行代码,直到通道中的信息被取出为止。
package main import ( "fmt" ) func f1(in chan int) { fmt.Println(<-in) } func main() { out := make(chan int) out <- 2 go f1(out) }
-
同步通道——使用带缓冲的通道:
我们可以在扩展的
make
命令中设置通道的容量,实现带缓冲的通道:buf := 100 ch := make(chan string, buf)
buf是通道可以同时容纳的信息最大个数(这里buf的类型是string),默认值设置为0,为阻塞的。
-
通道实现信号量模式:
我们将需要进行的操作放在函数中通过协程解决,在此之前,我们可以设置一个通道用来存储函数执行完毕的信号量,即当函数执行结束了我们就往其中添加信息,main程序监听到通道中的信息即结束程序。这样就比较好地防止了main程序需要延时等待其他协程的问题,更加地灵活。
func compute(ch chan int){ ch <- someComputation() // when it completes, signal on the channel. } func main(){ ch := make(chan int) // allocate a channel. go compute(ch) // stat something in a goroutines doSomethingElseForAWhile() result := <- ch }
-
实现并行的for循环:
for i, v := range data { go func (i int, v float64) { doSomething(i, v) ... } (i, v) }
注意:这里的
i,v
都是作为参数传入闭合函数的,从外层循环中隐藏了变量i,v
,让每一个协程都有一个独立的i,v
的拷贝,保证了变量的安全性(个人觉得如果这里是go协程直接访问而不是通过传参的方式可能会导致多个协程访问同一个值的情况)。 -
用带缓冲通道实现一个信号量:
信号量是实现互斥锁(排外锁)常见的同步机制,限制对资源的访问,解决读写问题,比如没有实现信号量的
sync
的 Go 包,使用带缓冲的通道可以轻松实现:- 带缓冲通道的容量和要同步的资源容量相同
- 通道的长度(当前存放的元素个数)与当前资源被使用的数量相同
- 容量减去通道的长度就是未处理的资源个数(标准信号量的整数值)
通道的创建,在编程中有一种常见的模式,称为通道工厂模式,在该模式中,由通道工厂创建通道,我们接受函数调用的返回值。以下代码即为其中的实现:
package main import ( "fmt" "time" ) func main() { stream := pump() go suck(stream) time.Sleep(1e9) } func pump() chan int { ch := make(chan int) go func() { for i := 0; ; i++ { ch <- i } }() return ch } func suck(ch chan int) { for { fmt.Println(<-ch) } }
-
for循环迭代通道:
for v := range ch { fmt.Printf("The value is %v\n", v) }
它从指定通道中读取数据直到通道关闭,才继续执行下边的代码。很明显,另外一个协程必须写入
ch
(不然代码就阻塞在 for 循环了),而且必须在写入完成后才关闭。我们可以将这一部分的代码写在函数闭包中,然后通过
go
关键字使用协程调用该函数,结合前面的通道工厂模式,代码可以这么写:package main import ( "fmt" "time" ) func main() { suck(pump()) time.Sleep(1e9) } func pump() chan int { ch := make(chan int) go func() { for i := 0; ; i++ { ch <- i } }() return ch } func suck(ch chan int) { go func() { for v := range ch { fmt.Println(v) } }() }
通道还可以用来实现容器的迭代,参考代码如下:
func (c *container) Iter () <- chan items { ch := make(chan item) go func () { for i:= 0; i < c.Len(); i++{ // or use a for-range loop ch <- c.items[i] } } () return ch }
-
通道的方向:
通道的类型可以是只出不进的,也可以是只进不出的,通过以下的方式进行定义:
var send_only chan<- int // channel can only receive data var recv_only <-chan int // channel can only send data
只接收的通道(<-chan T)无法关闭,因为关闭通道是发送者用来表示不再给通道发送值了,所以对只接收通道是没有意义的,也可以用来做函数的参数。
-
怎么解释这段代码的运行:
// Copyright 2009 The Go Authors. All rights reserved. // Use of this source code is governed by a BSD-style // license that can be found in the LICENSE file.package main package main import "fmt" // Send the sequence 2, 3, 4, ... to channel 'ch'. func generate(ch chan int) { for i := 2; ; i++ { ch <- i // Send 'i' to channel 'ch'. } } // Copy the values from channel 'in' to channel 'out', // removing those divisible by 'prime'. func filter(in, out chan int, prime int) { for { i := <-in // Receive value of new variable 'i' from 'in'. if i%prime != 0 { out <- i // Send 'i' to channel 'out'. } } } // The prime sieve: Daisy-chain filter processes together. func main() { ch := make(chan int) // Create a new channel. go generate(ch) // Start generate() as a goroutine. for { prime := <-ch fmt.Print(prime, " ") ch1 := make(chan int) go filter(ch, ch1, prime) ch = ch1 } }
3. 协程的同步:关闭通道-测试阻塞
通道可以被显式的关闭;尽管它们和文件不同:不必每次都关闭。只有在当需要告诉接收者不会再提供新的值的时候,才需要关闭通道。只有发送者需要关闭通道,接收者永远不会需要。
我们可以通过函数close(ch)
来完成通道的关闭,此时给已经关闭的通道发送或者再次关闭都会导致运行时的panic。在创建一个通道后使用defer语句关闭是一个不错的选择:
ch := make(chan float64)
defer close(ch)
可以通过逗号,ok操作符,用来检测通道是否被关闭,通常搭配if语句一起使用:
if v,ok := <-ch;ok{
process(v)
}
// 如果在for循环中使用参考以下代码:
v,ok := <-ch
if !ok{
break
}
process(v)
for input := range ch{
process(input)
}
4. 使用select切换协程:
从不同的并发执行的协程中获取值可以通过关键字select
完成,select
监听进入通道的数据,也可以是用通道发送值的时候:
select {
case u:= <- ch1:
...
case v:= <- ch2:
...
...
default: // no value ready to be received
...
}
default语句是可选的;fallthrough行为和普通的switch功能相似,但在这里是不被允许的。在任何一个case中执行break
或者return
,select就结束了。select的作用像是:选择处理列出的多个通道中的一个。
- 如果都阻塞了,会等待直到其中一个可以处理
- 如果多个可以处理,随机选择一个
- 如果没有通道操作可以处理并且写了
default
语句,它就会执行:default
永远是可运行的(这就是准备好了,可以执行)
在 select
中使用发送操作并且有 default
可以确保发送不被阻塞!如果没有 case,select 就会一直阻塞。
5. 通道、超时和计时器:
-
计时器Ticker:
time
包中有一些有趣的功能可以和通道组合使用,其中就包含了time.Ticker
结构体,这个对象以指定的时间间隔重复地向通道C发送时间值:type Ticker struct { C <-chan Time // the channel on which the ticks are delivered. // contains filtered or unexported fields ... }
时间间隔的单位是ns,
time.Ticker
类型对象通过time.NewTicker(dur) *Ticker
函数来创建,其中的参数dur为Duration
类型的变量。Ticker
在协程周期性的执行一些事情(打印状态日志,输出,计算等等)的时候非常有用。ticker := time.NewTicker(updateInterval) defer ticker.Stop() ... select { case u:= <-ch1: ... case v:= <-ch2: ... case <-ticker.C: logState(status) // call some logging function logState default: // no value ready to be received ... }
调用
Stop()
使计时器停止,在defer
语句中使用。time.Tick(dur)
函数可以省去Ticker
直接获得一个可以周期性获取消息的通道,这样就不用考虑关闭Ticker
的问题。参考以下的代码:import "time" rate_per_sec := 10 var dur Duration = 1e9 / rate_per_sec chRate := time.Tick(dur) // a tick every 1/10th of a second for req := range requests { <- chRate // rate limit our Service.Method RPC calls go client.Call("Service.Method", req, ...) }
这里
client.Call()
是一个远程调用,暂时不用管。通过这种方式,就可以使得代码按照指定的频率处理请求,chRate
阻塞了更高的频率。 -
定时器Timer:
定时器(Timer)结构体看上去和计时器(Ticker)结构体的确很像(构造为
NewTimer(d Duration)
),但是它只发送一次时间,在Dration d
之后。同理,与
Tick()
函数对应的有一个After()
函数,但是After()
只发送一次时间。应用:
-
简单超时:
要从通道
ch
中接收数据,但是最多等待 1 秒。先创建一个信号通道,然后启动一个lambda
协程,协程在给通道发送数据之前是休眠的。然后使用select
语句接收ch
或者timeout
的数据:如果ch
在 1 秒内没有收到数据,就选择到了time
分支并放弃了ch
的读取。timeout := make(chan bool, 1) go func() { time.Sleep(1e9) // one second timeout <- true }() // 这里也可以选择使用计时器Timer select { case <-ch: // a read from ch has occured case <-timeout: // the read from ch has timed out break }
-
取消很长耗时的同步调用:
可以在 select 中通过 time.After() 发送的超时信号来停止协程的执行。以下代码,在 timeoutNs 纳秒后执行 select 的 timeout 分支后,执行 client.Call 的协程也随之结束,不会给通道 ch 返回值:
ch := make(chan error, 1) go func() { ch <- client.Call("Service.Method", args, &reply) } () select { case resp := <-ch // use resp and reply case <-time.After(timeoutNs): // call timed out break }
-
实现"抢答":
假设程序从多个复制的数据库同时读取。只需要一个答案,需要接收首先到达的答案,
Query
函数获取数据库的连接切片并请求。并行请求每一个数据库并返回收到的第一个响应:func Query(conns []conn, query string) Result { ch := make(chan Result, 1) for _, conn := range conns { go func(c Conn) { select { case ch <- c.DoQuery(query): default: } }(conn) } return <- ch }
这里需要注意的地方是,结果通道必须是带缓冲的,否则可能导致死锁。
-
6. 协程和恢复:
一个用到recover
的程序,可以停掉服务器内部一个失败的协程而不影响其他协程的工作。
func server(workChan <-chan *Work) {
for work := range workChan {
go safelyDo(work) // start the goroutine for that work
}
}
func safelyDo(work *Work) {
defer func() {
if err := recover(); err != nil {
log.Printf("Work failed with %s in %v", err, work)
}
}()
do(work)
}
7. 新旧模型对比:
假设我们需要处理很多任务,一个worker处理一个任务,任务可以被定义成一个结构体:
type Task struct {
// some state
}
我们使用任务池来共享内存,将任务交给多协程处理的过程中,我们需要把对于任务池的操作做加锁解锁处理,以保证共享资源的安全性,因此我们设计任务池的结构如下:
type Pool struct {
Mu sync.Mutex
Tasks []Task
}
在这种模式下,我们的任务执行代码应该写成以下的形式:
func Worker(pool *Pool) {
for {
pool.Mu.lock()
// begin critical section:
task := pool.Task[0] // take the first task
pool.Tasks = pool.Task[1:] // update the pool of tasks
// end critical section
pool.Mu.Unlock()
process(task)
}
}
在这个过程中,加锁的操作保证了共享资源的安全,但是当工作协程数量很大,任务量也很多时,处理效率将会因为频繁的加锁 / 解锁开销而降低。
而在go语言中,加锁的问题我们可以使用通道来取代,使用一个通道接受需要处理的任务,一个通道接受处理完的任务。worker在协程中启动,其数量N应该根据任务数量进行调整。主线程在该过程中扮演者Master节点角色。
func main() {
pending, done := make(chan *Task), make(chan *Task)
go sendWork(pending) // put tasks with work on the channel
for i := 0; i < N; i++ { // start N goroutines to do work
go Worker(pending, done)
}
consumeWork(done) // continue with the processed tasks
}
func Worker(in, out chan *Task) {
for {
t := <-in
process(t)
out <- t
}
}
这里并不使用锁:从通道得到新任务的过程没有任何竞争。**(对一个通道读数据和写数据的整个过程是原子性的)**随着任务数量增加,worker 数量也应该相应增加,同时性能并不会像第一种方式那样下降明显。某一个任务会在哪一个 worker 中被执行是不可知的,反过来也是。
对于任何可以建模为Master-Worker范例的问题,一个类似于worker使用通道进行通信和交互、Master进行整体协调的方案都能完美解决。如果系统部署在多台机器上,各个机器上执行 Worker 协程,Master 和 Worker 之间使用 netchan 或者 RPC 远程调用进行通信。
那么什么情况下使用锁,什么情况下使用通道呢?
- 使用锁的情景:
- 访问共享数据结构中的缓存信息
- 保存应用程序上下文和状态信息数据
- 使用通道的情景:
- 与异步操作的结果进行交互
- 分发任务
- 传递数据所有权
8. 惰性生成器的实现:
生成器是指当被调用时返回一个序列中下一个值的函数。生成器每次返回的时序列中下一个值而非整个序列,这种特性也称之为惰性求值,我们可以通过协程的方式来实现惰性生成的特性:
package main
import (
"fmt"
)
var resume chan int
func integers() chan int {
yield := make(chan int)
count := 0
go func() {
for {
yield <- count
count++
}
}()
return yield
}
func generateInteger() int {
return <-resume
}
func main() {
resume = integers()
fmt.Println(generateInteger()) //=> 0
fmt.Println(generateInteger()) //=> 1
fmt.Println(generateInteger()) //=> 2
}
通过这段代码,我们可以实现int类型数据的惰性生成器,如果针对于各种各样不同类型的数据,想要得到其生成器,可以参考以下的代码:
package main
import (
"fmt"
)
type Any interface{}
type EvalFunc func(Any) (Any, Any)
func main() {
evenFunc := func(state Any) (Any, Any) {
os := state.(int)
ns := os + 2
return os, ns
}
//惰性生成每次递加的规则
even := BuildLazyIntEvaluator(evenFunc, 0)
//构建int类型的生成器,获得返回方法
for i := 0; i < 10; i++ {
fmt.Printf("%vth even: %v\n", i, even())
}
}
//惰性生成器的工厂,针对Any类型生效,可以进一步做类型转换
func BuildLazyEvaluator(evalFunc EvalFunc, initState Any) func() Any {
retValChan := make(chan Any)
loopFunc := func() {
var actState Any = initState
var retVal Any
for {
retVal, actState = evalFunc(actState)
retValChan <- retVal
}
}
retFunc := func() Any {
return <- retValChan
}
go loopFunc()
return retFunc
}
//针对int类型做类型转换,需要时补充目标类型的类型转换函数即可实现其余类型的惰性生成器
//需要所有的类型都遵循相同的递加规则
func BuildLazyIntEvaluator(evalFunc EvalFunc, initState Any) func() int {
ef := BuildLazyEvaluator(evalFunc, initState)
return func() int {
return ef().(int)
}
}
这里有一个点需要注意的是,在这两个惰性生成器的案例中,数据其实是在上一个数据被取出的时候就已经计算好并放置到通道中,等待代码的调用。而不是真正意义上的,在每一次调用时再进行计算然后返回计算结果。
9. 实现Futures模式:
Futures模式是指,有时候再使用某一个值之前需要先对其进行计算。这种情况下,你就可以在另一个处理器上进行该值的计算,到使用时,该值就已经计算完毕了,即做异步运算,运算结果使用通道存储。
func InverseProduct(a Matrix, b Matrix) {
a_inv_future := InverseFuture(a) // start as a goroutine
b_inv_future := InverseFuture(b) // start as a goroutine
a_inv := <-a_inv_future
b_inv := <-b_inv_future
return Product(a_inv, b_inv)
}
func InverseFuture(a Matrix) {
future := make(chan Matrix)
go func() {
future <- Inverse(a)
}()
return future
}
10. 多路复用:
在典型的客户端-服务端模型中,通道和协程就可以得到很好的应用。在Go中,服务端可以在一个协程里操作对一个客户端的响应,可以通过在请求中绑定通道,然后由服务端开启协程对客户端请求进行处理,最终将处理结果返回到通道中给到客户端,实现多路复用。
type Request struct {
a, b int;
// 这里的a,b是请求携带的参数
replyc chan int;
// 请求内部的回复 channel
}
同时,我们可以额外设置一个quit通道,用来接收停止服务的通知,告知服务端停止服务,避免无意义的监听以及强制停止。
11. 限制并发数:
在客户端-服务端模型中,我们可以将所有的请求都添加到请求通道中,服务端从请求通道中获取请求,通过设置请求通道的大小限制同一时间内的请求数量,实现限制并发数的功能。
12. 链式编程:
假设我们必须处理大量的彼此独立的数据项,通过一个输入通道进入,并且全部处理完成后放到一个输出通道,就像一个工厂的管道。每个数据项的处理也许会涉及多个步骤:预处理 / 步骤 A / 步骤 B / … / 后期处理。一个典型的代码样式如下:
func SerialProcessData (in <- chan *Data, out <- chan *Data) {
for data := range in {
tmpA := PreprocessData(data)
tmpB := ProcessStepA(tmpA)
tmpC := ProcessStepB(tmpB)
out <- PostProcessData(tmpC)
}
}
但在Go语言中,我们也可以将数据处理的结果直接放入通道中,而函数调用时的参数传入也直接从通道中读取,这样子可以保证,一旦某一个步骤处理完了,便可以立即开始下一个步骤,同时调用协程来处理该过程:
func ParallelProcessData (in <- chan *Data, out <- chan *Data) {
// make channels:
preOut := make(chan *Data, 100)
stepAOut := make(chan *Data, 100)
stepBOut := make(chan *Data, 100)
stepCOut := make(chan *Data, 100)
// start parallel computations:
go PreprocessData(in, preOut)
go ProcessStepA(preOut, stepAOut)
go ProcessStepB(stepAOut, stepBOut)
go ProcessStepC(stepBOut, stepCOut)
go PostProcessData(stepCOut, out)
}
13. 标杆分析:如何使用基准测试?N参数怎么设置?
14. 使用Channel来并发读取对象:
使用Channel可以有效地保护共享数据的安全,但是同时我们也应该思考,对于一个对象的读取,如果每次操作都是通过通道直接传递对象本身,是否存在更加简便的方式呢?
我们以一个结构体Person
为例,它包含了一个匿名函数类型的通道字段 chF。它在构造器方法NewPerson中初始化,用一个协程启动一个backend()
方法(该方法将set或者get这种并发操作添加到通道中保证其顺序执行)。这个方法在一个无限 for 循环中执行所有被放到 chF 上的函数,有效的序列化他们,从而提供安全的并发访问。
package main
import (
"fmt"
"strconv"
)
type Person struct {
Name string
salary float64
chF chan func()
}
func NewPerson(name string, salary float64) *Person {
p := &Person{name, salary, make(chan func())}
go p.backend()
return p
}
func (p *Person) backend() {
for f := range p.chF {
f()
}
}
// 设置 salary.
func (p *Person) SetSalary(sal float64) {
p.chF <- func() { p.salary = sal }
}
// 取回 salary.
func (p *Person) Salary() float64 {
fChan := make(chan float64)
p.chF <- func() { fChan <- p.salary }
return <-fChan
}
func (p *Person) String() string {
return "Person - name is: " + p.Name + " - salary is: " + strconv.FormatFloat(p.Salary(), 'f', 2, 64)
}
func main() {
bs := NewPerson("Smith Bill", 2500.5)
fmt.Println(bs)
bs.SetSalary(4000.25)
fmt.Println("Salary changed:")
fmt.Println(bs)
}
```