标签 C 下的文章

论golang Timer Reset方法使用的正确姿势

2016年,Go语言Tiobe编程语言排行榜上位次的大幅蹿升(2016年12月份Tiobe榜单:go位列第16位,Rating值:1.939%)。与此同时,我们也能切身感受到Go语言在世界范围蓬勃发展,其在中国地界儿上的发展更是尤为猛烈^0^:For gopher们的job变多了、网上关于Go的资料也大有“汗牛充栋”之势。作为职业Gopher^0^,要为这个生态添砖加瓦,就要多思考、多总结,关键还要做到“遇到了问题,就要说出来,给出你的见解”。每篇文章都有自己的切入角度和关注重点,因此Gopher们也无需过于担忧资料的“重复”。

这次,我来说说在使用Go标准库中Timer的Reset方法时遇到的问题。

一、关于Timer原理的一些说明

网络编程方面,从用户视角看,golang表象上是一种“阻塞式”网络编程范式,而支撑这种“阻塞式”范式的则是内置于go编译后的executable file中的runtime。runtime利用网络IO多路复用机制实现多个进行网络通信的goroutine的合理调度。goroutine中的执行函数则相当于你在传统C编程中传给epoll机制的回调函数。golang一定层度上消除了在这方面“回调”这种“逆向思维”给你带来的心智负担,简化了网络编程的复杂性。

但长时间“阻塞”显然不能满足大多数业务情景,因此还需要一定的超时机制。比如:在socket层面,我们通过显式设置net.Dialer的Timeout或使用SetReadDeadline、SetWriteDeadline以及SetDeadline;在应用层协议,比如http,client通过设置timeout参数,server通过TimeoutHandler来限制操作的time limit。这些timeout机制,有些是通过runtime的网络多路复用的timeout机制实现,有些则是通过Timer实现的。

标准库中的Timer让用户可以定义自己的超时逻辑,尤其是在应对select处理多个channel的超时、单channel读写的超时等情形时尤为方便。

1、Timer的创建

Timer是一次性的时间触发事件,这点与Ticker不同,后者则是按一定时间间隔持续触发时间事件。Timer常见的使用场景如下:

场景1:

t := time.AfterFunc(d, f)

场景2:

select {
    case m := <-c:
       handle(m)
    case <-time.After(5 * time.Minute):
       fmt.Println("timed out")
}

或:
t := time.NewTimer(5 * time.Minute)
select {
    case m := <-c:
       handle(m)
    case <-t.C:
       fmt.Println("timed out")
}

从这两个场景中,我们可以看到Timer三种创建姿势:

t:= time.NewTimer(d)
t:= time.AfterFunc(d, f)
c:= time.After(d)

虽然姿势不同,但背后的原理则是相通的。

Timer有三个要素:

* 定时时间:也就是那个d
* 触发动作:也就是那个f
* 时间channel: 也就是t.C

对于AfterFunc这种创建方式而言,Timer就是在超时(timer expire)后,执行函数f,此种情况下:时间channel无用。

//$GOROOT/src/time/sleep.go

func AfterFunc(d Duration, f func()) *Timer {
    t := &Timer{
        r: runtimeTimer{
            when: when(d),
            f:    goFunc,
            arg:  f,
        },
    }
    startTimer(&t.r)
    return t
}

func goFunc(arg interface{}, seq uintptr) {
    go arg.(func())()
}

注意:从AfterFunc源码可以看到,外面传入的f参数并非直接赋值给了内部的f,而是作为wrapper function:goFunc的arg传入的。而goFunc则是启动了一个新的goroutine来执行那个外部传入的f。这是因为timer expire对应的事件处理函数的执行是在go runtime内唯一的timer events maintenance goroutine: timerproc中。为了不block timerproc的执行,必须启动一个新的goroutine。

//$GOROOT/src/runtime/time.go
func timerproc() {
    timers.gp = getg()
    for {
        lock(&timers.lock)
        ... ...
            f := t.f
            arg := t.arg
            seq := t.seq
            unlock(&timers.lock)
            if raceenabled {
                raceacquire(unsafe.Pointer(t))
            }
            f(arg, seq)
            lock(&timers.lock)
        }
        ... ...
        unlock(&timers.lock)
   }
}

而对于NewTimer和After这两种创建方法,则是Timer在超时(timer expire)后,执行一个标准库中内置的函数:sendTime。sendTime将当前当前事件send到timer的时间Channel中,那么说这个动作不会阻塞到timerproc的执行么?答案肯定是不会的,其原因就在下面代码中:

//$GOROOT/src/time/sleep.go
func NewTimer(d Duration) *Timer {
    c := make(chan Time, 1)
    t := &Timer{
        C: c,
        ... ...
    }
    ... ...
    return t
}

func sendTime(c interface{}, seq uintptr) {
    // Non-blocking send of time on c.
    // Used in NewTimer, it cannot block anyway (buffer).
    // Used in NewTicker, dropping sends on the floor is
    // the desired behavior when the reader gets behind,
    // because the sends are periodic.
    select {
    case c.(chan Time) <- Now():
    default:
    }
}

我们看到NewTimer中创建了一个buffered channel,size = 1。正常情况下,当timer expire,t.C无论是否有goroutine在read,sendTime都可以non-block的将当前时间发送到C中;同时,我们看到sendTime还加了双保险:通过一个select判断c buffer是否已满,一旦满了,直接退出,依然不会block,这种情况在reuse active timer时可能会遇到。

2、Timer的资源释放

很多Go初学者在使用Timer时都会担忧Timer的创建会占用系统资源,比如:

有人会认为:创建一个Timer后,runtime会创建一个单独的Goroutine去计时并在expire后发送当前时间到channel里。
还有人认为:创建一个timer后,runtime会申请一个os级别的定时器资源去完成计时工作。

实际情况并不是这样。恰好近期gopheracademy blog发布了一篇 《How Do They Do It: Timers in Go》,通过对timer源码的分析,讲述了timer的原理,大家可以看看。

go runtime实际上仅仅是启动了一个单独的goroutine,运行timerproc函数,维护了一个”最小堆”,定期wake up后,读取堆顶的timer,执行timer对应的f函数,并移除该timer element。创建一个Timer实则就是在这个最小堆中添加一个element,Stop一个timer,则是从堆中删除对应的element。

同时,从上面的两个Timer常见的使用场景中代码来看,我们并没有显式的去释放什么。从上一节我们可以看到,Timer在创建后可能占用的资源还包括:

  • 0或一个Channel
  • 0或一个Goroutine

这些资源都会在timer使用后被GC回收。

综上,作为Timer的使用者,我们要做的就是尽量减少在使用Timer时对最小堆管理goroutine和GC的压力即可,即:及时调用timer的Stop方法从最小堆删除timer element(如果timer 没有expire)以及reuse active timer。

BTW,这里还有一篇讨论go Timer精度的文章,大家可以拜读一下。

二、Reset到底存在什么问题?

铺垫了这么多,主要还是为了说明Reset的使用问题。什么问题呢?我们来看下面的例子。这些例子主要是为了说明Reset问题,现实中很可能大家都不这么写代码逻辑。当前环境:go version go1.7 darwin/amd64。

1、example1

我们的第一个example如下:

//example1.go

func main() {
    c := make(chan bool)

    go func() {
        for i := 0; i < 5; i++ {
            time.Sleep(time.Second * 1)
            c <- false
        }

        time.Sleep(time.Second * 1)
        c <- true
    }()

    go func() {
        for {
            // try to read from channel, block at most 5s.
            // if timeout, print time event and go on loop.
            // if read a message which is not the type we want(we want true, not false),
            // retry to read.
            timer := time.NewTimer(time.Second * 5)
            defer timer.Stop()
            select {
            case b := <-c:
                if b == false {
                    fmt.Println(time.Now(), ":recv false. continue")
                    continue
                }
                //we want true, not false
                fmt.Println(time.Now(), ":recv true. return")
                return
            case <-timer.C:
                fmt.Println(time.Now(), ":timer expired")
                continue
            }
        }
    }()

    //to avoid that all goroutine blocks.
    var s string
    fmt.Scanln(&s)
}

example1.go的逻辑大致就是 一个consumer goroutine试图从一个channel里读出true,如果读出false或timer expire,那么继续try to read from the channel。这里我们每次循环都创建一个timer,并在go routine结束后Stop该timer。另外一个producer goroutine则负责生产消息,并发送到channel中。consumer中实际发生的行为取决于producer goroutine的发送行为。

example1.go执行的结果如下:

$go run example1.go
2016-12-21 14:52:18.657711862 +0800 CST :recv false. continue
2016-12-21 14:52:19.659328152 +0800 CST :recv false. continue
2016-12-21 14:52:20.661031612 +0800 CST :recv false. continue
2016-12-21 14:52:21.662696502 +0800 CST :recv false. continue
2016-12-21 14:52:22.663531677 +0800 CST :recv false. continue
2016-12-21 14:52:23.665210387 +0800 CST :recv true. return

输出如预期。但在这个过程中,我们新创建了6个Timer。

2、example2

如果我们不想重复创建这么多Timer实例,而是reuse现有的Timer实例,那么我们就要用到Timer的Reset方法,见下面example2.go,考虑篇幅,这里仅列出consumer routine代码,其他保持不变:

//example2.go
.... ...
// consumer routine
    go func() {
        // try to read from channel, block at most 5s.
        // if timeout, print time event and go on loop.
        // if read a message which is not the type we want(we want true, not false),
        // retry to read.
        timer := time.NewTimer(time.Second * 5)
        for {
            // timer is active , not fired, stop always returns true, no problems occurs.
            if !timer.Stop() {
                <-timer.C
            }
            timer.Reset(time.Second * 5)
            select {
            case b := <-c:
                if b == false {
                    fmt.Println(time.Now(), ":recv false. continue")
                    continue
                }
                //we want true, not false
                fmt.Println(time.Now(), ":recv true. return")
                return
            case <-timer.C:
                fmt.Println(time.Now(), ":timer expired")
                continue
            }
        }
    }()
... ...

按照go 1.7 doc中关于Reset使用的建议:

To reuse an active timer, always call its Stop method first and—if it had expired—drain the value from its channel. For example:

if !t.Stop() {
        <-t.C
}
t.Reset(d)

我们改造了example1,形成example2的代码。由于producer行为并未变更,实际example2执行时,每次循环Timer在被Reset之前都没有expire,也没有fire a time to channel,因此timer.Stop的调用均返回true,即成功将timer从“最小堆”中移除。example2的执行结果如下:

$go run example2.go
2016-12-21 15:10:54.257733597 +0800 CST :recv false. continue
2016-12-21 15:10:55.259349877 +0800 CST :recv false. continue
2016-12-21 15:10:56.261039127 +0800 CST :recv false. continue
2016-12-21 15:10:57.262770422 +0800 CST :recv false. continue
2016-12-21 15:10:58.264534647 +0800 CST :recv false. continue
2016-12-21 15:10:59.265680422 +0800 CST :recv true. return

和example1并无二致。

3、example3

现在producer routine的发送行为发生了变更:从以前每隔1s发送一次数据变成了每隔7s发送一次数据,而consumer routine不变:

//example3.go

//producer routine
    go func() {
        for i := 0; i < 10; i++ {
            time.Sleep(time.Second * 7)
            c <- false
        }

        time.Sleep(time.Second * 7)
        c <- true
    }()

我们来看看example3.go的执行结果:

$go run example3.go
2016-12-21 15:14:32.764410922 +0800 CST :timer expired

程序hang住了。你能猜到在哪里hang住的吗?对,就是在drain t.C的时候hang住了:

           // timer may be not active and may not fired
            if !timer.Stop() {
                <-timer.C //drain from the channel
            }
            timer.Reset(time.Second * 5)

producer的发送行为发生了变化,Comsumer routine在收到第一个数据前有了一次time expire的事件,for loop回到loop的开始端。这时timer.Stop函数返回的不再是true,而是false,因为timer已经expire,最小堆中已经不包含该timer了,Stop在最小堆中找不到该timer,返回false。于是example3代码尝试抽干(drain)timer.C中的数据。但timer.C中此时并没有数据,于是routine block在channel recv上了。

在Go 1.8以前版本中,很多人遇到了类似的问题,并提出issue,比如:

time: Timer.Reset is not possible to use correctly #14038

不过go team认为这还是文档中对Reset的使用描述不够充分导致的,于是在Go 1.8中对Reset方法的文档做了补充Go 1.8 beta2中Reset方法的文档改为了:

Resetting a timer must take care not to race with the send into t.C that happens when the current timer expires. If a program has already received a value from t.C, the timer is known to have expired, and t.Reset can be used directly. If a program has not yet received a value from t.C, however, the timer must be stopped and—if Stop reports that the timer expired before being stopped—the channel explicitly drained:

if !t.Stop() {
        <-t.C
}
t.Reset(d)

大致意思是:如果明确time已经expired,并且t.C已经被取空,那么可以直接使用Reset;如果程序之前没有从t.C中读取过值,这时需要首先调用Stop(),如果返回true,说明timer还没有expire,stop成功删除timer,可直接reset;如果返回false,说明stop前已经expire,需要显式drain channel。

4、example4

我们的example3就是“time已经expired,并且t.C已经被取空,那么可以直接使用Reset ”这第一种情况,我们应该直接reset,而不用显式drain channel。如何将这两种情形合二为一,很直接的想法就是增加一个开关变量isChannelDrained,标识timer.C是否已经被取空,如果取空,则直接调用Reset。如果没有,则drain Channel。

增加一个变量总是麻烦的,RussCox也给出一个未经详尽验证的方法,我们来看看用这种方法改造的example4.go:

//example4.go

//consumer
    go func() {
        // try to read from channel, block at most 5s.
        // if timeout, print time event and go on loop.
        // if read a message which is not the type we want(we want true, not false),
        // retry to read.
        timer := time.NewTimer(time.Second * 5)
        for {
            // timer may be not active, and fired
            if !timer.Stop() {
                select {
                case <-timer.C: //try to drain from the channel
                default:
                }
            }
            timer.Reset(time.Second * 5)
            select {
            case b := <-c:
                if b == false {
                    fmt.Println(time.Now(), ":recv false. continue")
                    continue
                }
                //we want true, not false
                fmt.Println(time.Now(), ":recv true. return")
                return
            case <-timer.C:
                fmt.Println(time.Now(), ":timer expired")
                continue
            }
        }
    }()

执行结果:

$go run example4.go
2016-12-21 15:38:16.704647957 +0800 CST :timer expired
2016-12-21 15:38:18.703107177 +0800 CST :recv false. continue
2016-12-21 15:38:23.706665507 +0800 CST :timer expired
2016-12-21 15:38:25.705314522 +0800 CST :recv false. continue
2016-12-21 15:38:30.70900638 +0800 CST :timer expired
2016-12-21 15:38:32.707482917 +0800 CST :recv false. continue
2016-12-21 15:38:37.711260142 +0800 CST :timer expired
2016-12-21 15:38:39.709668705 +0800 CST :recv false. continue
2016-12-21 15:38:44.71337522 +0800 CST :timer expired
2016-12-21 15:38:46.710880007 +0800 CST :recv false. continue
2016-12-21 15:38:51.713813305 +0800 CST :timer expired
2016-12-21 15:38:53.713063822 +0800 CST :recv true. return

我们利用一个select来包裹channel drain,这样无论channel中是否有数据,drain都不会阻塞住。看似问题解决了。

5、竞争条件

如果你看过timerproc的代码,你会发现其中的这样一段代码:

// go1.7
// $GOROOT/src/runtime/time.go
            f := t.f
            arg := t.arg
            seq := t.seq
            unlock(&timers.lock)
            if raceenabled {
                raceacquire(unsafe.Pointer(t))
            }
            f(arg, seq)
            lock(&timers.lock)

我们看到在timerproc执行f(arg, seq)这个函数前,timerproc unlock了timers.lock,也就是说f的执行并没有在锁内。

前面说过,f的执行是什么?

对于AfterFunc来说,就是启动一个goroutine,并在这个新goroutine中执行用户传入的函数;
对于After和NewTimer这种创建姿势创建的timer而言,f的执行就是sendTime的执行,也就是向t.C中send 当前时间。

注意:这时候timer expire过程中sendTime的执行与“drain channel”是分别在两个goroutine中执行的,谁先谁后,完全依靠runtime调度。于是example4.go中的看似没有问题的代码,也可能存在问题(当然需要时间粒度足够小,比如ms级的Timer)。

如果sendTime的执行发生在drain channel执行前,那么就是example4.go中的执行结果:Stop返回false(因为timer已经expire了),显式drain channel会将数据读出,后续Reset后,timer正常执行;
如果sendTime的执行发生在drain channel执行后,那么问题就来了,虽然Stop返回false(因为timer已经expire),但drain channel并没有读出任何数据。之后,sendTime将数据发到channel中。timer Reset后的Timer中的Channel实际上已经有了数据,于是当进入下面的select执行体时,”case <-timer.C:”瞬间返回,触发了timer事件,没有启动超时等待的作用。

这也是issue:*time: Timer.C can still trigger even after Timer.Reset is called #11513中问到的问题。

go官方文档中对此也有描述:

Note that it is not possible to use Reset's return value correctly, as there is a race condition between draining the channel and the new timer expiring. Reset should always be invoked on stopped or expired channels, as described above. The return value exists to preserve compatibility with existing programs.

三、真的有Reset方法的正确使用姿势吗?

综合上述例子和分析,Reset的使用似乎没有理想的方案,但一般来说,在特定业务逻辑下,Reset还是可以正常工作的,就如example4那样。即便出现问题,如果了解了Reset背后的原理,问题解决起来也是会很快很准的。

文中的相关代码可以在这里下载

四、参考资料

Golang官方有关Timer的issue list:

runtime: special case timer channels #8898
time:timer stop ,how to use? #14947
time: document proper usage of Timer.Stop #14383
*time: Timer.Reset is not possible to use correctly #14038
Time.After doesn’t release memory #15781
runtime: timerproc does not get to run under load #15706
time: time.After uses memory until duration times out #15698
time:timer stop panic #14946
*time: Timer.C can still trigger even after Timer.Reset is called #11513
time: Timer.Stop documentation incorrect for Timer returned by AfterFunc #17600

相关资料:

  1. go中的定时器timer
  2. Go内部实现之timer
  3. Go定时器
  4. How Do They Do It: Timers in Go
  5. timer在go可以有多精确

Go语言错误处理

近期闲暇用Go写一个lib,其中涉及到error处理的地方让我琢磨了许久。关于Go错误处理的资料和视频已有许多,Go authors们也在官方Articles和Blog上多次提到过一些Go error handling方面的一些tips和best practice,这里仅仅算是做个收集和小结,尽视野所及,如有不足,欢迎评论中补充。(10月因各种原因,没有耕博,月末来一发,希望未为晚矣 ^_^)

一、概述

Go是一门simple language,常拿出来鼓吹的就是作为gopher习以为傲的仅仅25个关键字^_^。因此Go的错误处理也一如既往的简单。我们知道C语言错误处理以返 回错误码(errno)为主流,目前企业第一语言Java则用try-catch- finally的处理方式来统一应对错误和异常(开发人员常常因分不清楚到底哪些是错误,哪些是异常而滥用该机制)。Go则继承了C,以返回值为错误处理的主要方式(辅以panic与recover应对runtime异常)。但与C不同的是,在Go的惯用法中,返回值不是整型等常用返回值类型,而是用了一个 error(interface类型)。

type interface error {
    Error() string
}

这也体现了Go哲学中的“正交”理念:error context与error类型的分离。无论error context是int、float还是string或是其他,统统用error作为返回值类型即可。

func yourFunction(parametersList) (..., error)
func (Receiver)yourMethod(parametersList) (..., error)

在Andrew Gerrand的“Error handling and Go“一文中,这位Go authors之一明确了error context是由error接口实现者supply的。在Go标准库中,Go提供了两种创建一个实现了error interface的类型的变量实例的方法:errors.New和fmt.Errorf:

errors.New("your first error code")
fmt.Errorf("error value is %d\n", errcode)

这两个方法实际上返回的是同一个实现了error interface的类型实例,这个unexported类型就是errorString。顾名思义,这个error type仅提供了一个string的context!

//$GOROOT/srcerrors/errors.go

type errorString struct {
    s string
}

func (e *errorString) Error() string {
    return e.s
}

这两个方法也基本满足了大部分日常学习和开发中代码中的错误处理需求。

二、惯用法(idiomatic usage)

1、基本用法

就像上面函数或方法定义那样:

func yourFunction(parametersList) (..., error)
func (Receiver)yourMethod(parametersList) (..., error)

通常情况,我们将函数或方法定义中的最后一个返回值类型定义为error。使用该函数或方法时,通过如下方式判断错误码:

..., err := yourFunction(...)
if err != nil {
    //error handling
}

or

if ..., err := yourFunction(...); err != nil {
    //error handling
}

2、注意事项

1)、永远不要忽略(ignore)函数或方法返回的错误码,Check it。(例外:包括标准库在内的Go代码很少去判断fmt.Println or Printf系列函数的返回值)

2)、error的string context中的内容格式:头母小写,结尾不带标点。因为考虑到error被经常这么用:

... err := errors.New("error example")
fmt.Printf("The returned error is %s.\n", err)

3)、error处理流的缩进样式

prefer

..., err := yourFunction(...)
if err != nil {
    // handle error
}

//go on doing something.

rather than:

..., err := yourFunction(...)
if err == nil {
    // do something.
}

// handle error

三、槽点与破解之法

Go自诞生那天起就伴随着巨大争议,这也不奇怪,就像娱乐圈,如果没有争议,哪有存在感,刷脸的机会都没有。看来有争议是件好事,没争议的编程语言都已经成为了历史。炒作懂么!这也是很多Gopher的微博、微信、twitter、medium账号喜欢发“Why I do not like Go”类文章的原因吧^_^。

Go的error处理方式就是被诟病的点之一,反方主要论点就是Go的错误处理机制似乎回到了70年代(与C同龄^_^),使得错误处理代码冗长且重复(部分也是由于前面提到的:不要ignore任何一个错误码),比如一些常见的错误处理代码形式如下:

err := doStuff1()
if err != nil {
    //handle error...
}

err = doStuff2()
if err != nil {
    //handle error...
}

err = doStuff3()
if err != nil {
    //handle error...
}

这里不想去反驳这些论点,Go authors之一的Russ Cox对于这种观点进行过驳斥:当初选择返回值这种错误处理机制而不是try-catch这种机制,主要是考虑前者适用于大型软件,后者更适合小程序。当程序变大,try-catch会让错误处理更加冗长繁琐易出错(具体参见go faq)。不过Russ Cox也承认Go的错误处理机制对于开发人员的确有一定的心智负担。

好了,关于这个槽点的叙述点到为止,我们关心的是“如何破解”!Go的错误处理的确冗长,但使用一些tips,还是可以将代码缩减至可以忍受的范围的,这里列举三种:

1、checkError style

对于一些在error handle时可以选择goroutine exit(注意:如果仅存main goroutine一个goroutine,调用runtime.Goexit会导致program以crash形式退出)或os.Exit的情形,我们可以选择类似常见的checkError方式简化错误处理,例如:

func checkError(err error) {
    if err != nil {
        fmt.Println("Error is ", err)
        os.Exit(-1)
    }
}

func foo() {
    err := doStuff1()
    checkError(err)

    err = doStuff2()
    checkError(err)

    err = doStuff3()
    checkError(err)
}

这种方式有些类似于C中用宏(macro)简化错误处理过程代码,只是由于Go不支持宏,使得这种方式的应用范围有限。

2、聚合error handle functions

有些时候,我们会遇到这样的情况:

err := doStuff1()
if err != nil {
    //handle A
    //handle B
    ... ...
}

err = doStuff2()
if err != nil {
    //handle A
    //handle B
    ... ...
}

err = doStuff3()
if err != nil {
    //handle A
    //handle B
    ... ...
}

在每个错误处理过程,处理过程相似,都是handle A、handle B等,我们可以通过Go提供的defer + 闭包的方式,将handle A、handle B…聚合到一个defer匿名helper function中去:

func handleA() {
    fmt.Println("handle A")
}
func handleB() {
    fmt.Println("handle B")
}

func foo() {
    var err error
    defer func() {
        if err != nil {
            handleA()
            handleB()
        }
    }()

    err = doStuff1()
    if err != nil {
        return
    }

    err = doStuff2()
    if err != nil {
        return
    }

    err = doStuff3()
    if err != nil {
        return
    }
}

3、 将doStuff和error处理绑定

在Rob Pike的”Errors are values”一文中,Rob Pike told us 标准库中使用了一种简化错误处理代码的trick,bufio的Writer就使用了这个trick:

    b := bufio.NewWriter(fd)
    b.Write(p0[a:b])
    b.Write(p1[c:d])
    b.Write(p2[e:f])
    // and so on
    if b.Flush() != nil {
            return b.Flush()
        }
    }

我们看到代码中并没有判断三个b.Write的返回错误值,错误处理放在哪里了呢?我们打开一下$GOROOT/src/

type Writer struct {
    err error
    buf []byte
    n   int
    wr  io.Writer
}

func (b *Writer) Write(p []byte) (nn int, err error) {
    for len(p) > b.Available() && b.err == nil {
        ... ...
    }
    if b.err != nil {
        return nn, b.err
    }
    ......
    return nn, nil
}

我们可以看到,错误处理被绑定在Writer.Write的内部了,Writer定义中有一个err作为一个错误状态值,与Writer的实例绑定在了一起,并且在每次Write入口判断是否为!= nil。一旦!=nil,Write其实什么都没做就return了。

以上三种破解之法,各有各的适用场景,同样你也可以看出各有各的不足,没有普适之法。优化go错误处理之法也不会局限在上述三种情况,肯定会有更多的solution,比如代码生成,比如其他还待发掘。

四、解调用者之惑

前面举的例子对于调用者来讲都是较为简单的情况了。但实际编码中,调用者不仅要面对的是:

if err != nil {
    //handle error
}

还要面对:

if err 是 ErrXXX
    //handle errorXXX

if err 是 ErrYYY
    //handle errorYYY

if err 是ErrZZZ
    //handle errorZZZ

我们分三种情况来说明调用者该如何处理不同类型的error实现:

1、由errors.New或fmt.Errorf返回的错误变量

如果你调用的函数或方法返回的错误变量是调用errors.New或fmt.Errorf而创建的,由于errorString类型是unexported的,因此我们无法通过“相当判定”或type assertion、type switch来区分不同错误变量的值或类型,唯一的方法就是判断err.String()是否与某个错误context string相等,示意代码如下:

func openFile(name string) error {
    if file not exist {
        return errors.New("file does not exist")
    }

    if have no priviledge {
        return errors.New("no priviledge")
    }
    return nil
}

func main() {
    err := openFile("example.go")
    if err.Error() == "file does not exist" {
        // handle "file does not exist" error
        return
    }

    if err.Error() == "no priviledge" {
        // handle "no priviledge" error
        return
    }
}

但这种情况太low了,不建议这么做!一旦遇到类似情况,就要考虑通过下面方法对上述情况进行重构。

2、exported Error变量

打开$GOROOT/src/os/error.go,你会在文件开始处发现如下代码:

var (
    ErrInvalid    = errors.New("invalid argument")
    ErrPermission = errors.New("permission denied")
    ErrExist      = errors.New("file already exists")
    ErrNotExist   = errors.New("file does not exist")
)

这些就是os包export的错误码变量,由于是exported的,我们在调用os包函数返回后判断错误码时可以直接使用等于判定,比如:

err := os.XXX
if err == os.ErrInvalid {
    //handle invalid
}
... ...

也可以使用switch case:

switch err := os.XXX {
    case ErrInvalid:
        //handle invalid
    case ErrPermission:
        //handle no permission
    ... ...
}
... ...

(至于error类型变量与os.ErrInvalid的可比较性可参考go specs

一般对于库的设计和实现者而言,在库的设计时就要考虑好export出哪些错误变量。

3、定义自己的error接口实现类型

如果要提供额外的error context,我们可以定义自己的实现error接口的类型;如果这些类型还是exported的,我们就可以用type assertion or type switch来判断返回的错误码类型并予以对应处理。

比如$GOROOT/src/net/net.go:

type OpError struct {
    Op string
    Net string
    Source Addr
    Addr Addr
    Err error
}

func (e *OpError) Error() string {
    if e == nil {
        return "<nil>"
    }
    s := e.Op
    if e.Net != "" {
        s += " " + e.Net
    }
    if e.Source != nil {
        s += " " + e.Source.String()
    }
    if e.Addr != nil {
        if e.Source != nil {
            s += "->"
        } else {
            s += " "
        }
        s += e.Addr.String()
    }
    s += ": " + e.Err.Error()
    return s
}

net.OpError提供了丰富的error Context,不仅如此,它还实现了除Error以外的其他method,比如:Timeout(实现net.timeout interface) 和Temporary(实现net.temporary interface)。这样我们在处理error时,可通过type assertion或type switch将error转换为*net.OpError,并调用到Timeout或Temporary方法来实现一些特殊的判定。

err := net.XXX
if oe, ok := err.(*OpError); ok {
    if oe.Timeout() {
        //handle timeout...
    }
}

五、坑(s)

每种编程语言都有自己的专属坑(s),Go虽出身名门,但毕竟年轻,坑也不少,在error处理这块也可以列出几个。

1、 Go FAQ:Why is my nil error value not equal to nil?

type MyError string

func (e *MyError) Error() string {
    return string(*e)
}

var ErrBad = MyError("ErrBad")

func bad() bool {
    return false
}

func returnsError() error {
    var p *MyError = nil
    if bad() {
        p = &ErrBad
    }
    return p // Will always return a non-nil error.
}

func main() {
    err := returnsError()
    if err != nil {
        fmt.Println("return non-nil error")
        return
    }
    fmt.Println("return nil")
}

上面的输出结果是”return non-nil error”,也就是说returnsError返回后,err != nil。err是一个interface类型变量,其underlying有两部分组成:类型和值。只有这两部分都为nil时,err才为nil。但returnsError返回时将一个值为nil,但类型为*MyError的变量赋值为err,这样err就不为nil。解决方法:

func returnsError() error {
    var p *MyError = nil
    if bad() {
        p = &ErrBad
        return p
    }
    return nil
}

2、switch err.(type)的匹配次序

试想一下下面代码的输出结果:

type MyError string

func (e MyError) Error() string {
    return string(e)
}

func Foo() error {
    return MyError("foo error")
}

func main() {
    err := Foo()
    switch e := err.(type) {
    default:
        fmt.Println("default")
    case error:
        fmt.Println("found an error:", e)
    case MyError:
        fmt.Println("found MyError:", e)
    }
    return

}

你可能会以为会输出:”found MyError: foo error”,但实际输出却是:”found an error: foo error”,也就是说e先匹配到了error!如果我们调换一下次序呢:

... ...
func main() {
    err := Foo()
    switch e := err.(type) {
    default:
        fmt.Println("default")
    case MyError:
        fmt.Println("found MyError:", e)
    case error:
        fmt.Println("found an error:", e)
    }
    return
}

这回输出结果变成了:“found MyError: foo error”。

也许你会认为这不全是错误处理的坑,和switch case的匹配顺序有关,但不可否认的是有些人会这么去写代码,一旦这么写,坑就踩到了。因此对于通过switch case来判定error type的情况,将error这个“通用”类型放在后面或去掉。

六、第三方库

如果觉得go内置的错误机制不能很好的满足你的需求,本着“do not reinvent the wheel”的精神,建议使用一些第三方库来满足,比如:juju/errors。这里就不赘述了。

如发现本站页面被黑,比如:挂载广告、挖矿等恶意代码,请朋友们及时联系我。十分感谢! Go语言第一课 Go语言精进之路1 Go语言精进之路2 Go语言编程指南
商务合作请联系bigwhite.cn AT aliyun.com

欢迎使用邮件订阅我的博客

输入邮箱订阅本站,只要有新文章发布,就会第一时间发送邮件通知你哦!

这里是 Tony Bai的个人Blog,欢迎访问、订阅和留言! 订阅Feed请点击上面图片

如果您觉得这里的文章对您有帮助,请扫描上方二维码进行捐赠 ,加油后的Tony Bai将会为您呈现更多精彩的文章,谢谢!

如果您希望通过微信捐赠,请用微信客户端扫描下方赞赏码:

如果您希望通过比特币或以太币捐赠,可以扫描下方二维码:

比特币:

以太币:

如果您喜欢通过微信浏览本站内容,可以扫描下方二维码,订阅本站官方微信订阅号“iamtonybai”;点击二维码,可直达本人官方微博主页^_^:
本站Powered by Digital Ocean VPS。
选择Digital Ocean VPS主机,即可获得10美元现金充值,可 免费使用两个月哟! 著名主机提供商Linode 10$优惠码:linode10,在 这里注册即可免费获 得。阿里云推荐码: 1WFZ0V立享9折!


View Tony Bai's profile on LinkedIn
DigitalOcean Referral Badge

文章

评论

  • 正在加载...

分类

标签

归档



View My Stats