标签 C 下的文章

使用Visual Studio Code辅助Go源码编写

作为VIMer,日常编码中,Vim编辑器依然是我的首选。以前以C语言为主要语言的时候是这样,现在以Go为主要语言时亦是这样。不过近期发现Mac上使用Vim在编写Go代码时,Vim时不时的“抽风”:出现一些“屏幕字符被篡改”的问题,比如下面这幅图中”func”变成了”fknc”:

虽然一段时间后,显示会自动更正过来,但这种“篡改”是会让你产生“幻觉”的。你会想:是不是我真的将”func”写成”fknc”了呢?久而久之,这个瑕疵将会影响你的编码效率。至于为何会出现这个问题,初步怀疑可能是因为vim加载较多插件导致的一些性能问题,我在安装了Ubuntu 16.04的台式机上至今还没发现这个问题(相同的.vimrc配置)。

于是,我打算找一款辅助编辑器,用于在被上面这个问题折磨得开始“厌恶”Vim的某些时候,切换一下,平复一下心情^0^。我看中了Microsoft开源的Visual Studio Code,简称:VSCode。

一、与Microsoft的Visual Studio的渊源

Microsoft做IDE还是很专业的,也是很认真的。大学那时候学C,嫌弃Turbo C太简陋,基本上都是在D版Visual Studio 6.0上完成各种作业和小程序的制作的。后来在2001年微软发布了.net战略,发布了C#语言,同时也发布了Visual Studio .NET IDE。估计我也算是国内第一批使用到Visual Studio.NET IDE的人吧,那时候微软俱乐部在校园里免费发送Vs.net beta版光盘,我拿到了一份,并第一时间体验了vs.net。Visual Studio .NET与之前的VS 6.0有着天壤之别,功能强大,界面也做了重新设计,支持微软的各种语言,包括C#、C/C++(包括managed c++)、VB、ASP.net等,并在一年后的正式版发布后,逐渐在桌面应用程序开发中成为霸主,把那个时候在IDE领域的竞争对手Borland公司彻底打垮。但Visual Studio从此也变得更加庞大和臃肿,安装一个VS,没有几个G空间是不行的。想想那个时候机器的配置,跑个VS.net还真是心有而力不足。

工作之后,进入服务端编程领域,结识了Unix、Linux以及VimGCC,就再也没怎么碰过Visual Studio。随着工作OS也从Windows切换到Ubuntu,基本就和VS绝缘了。之后随着Java语言成为企业级应用的主角、Web时代的到来以及开源IDE(比如:Eclipse)的兴起,微软的Visual Studio不再那么耀眼,或者说是人们对于IDE的关注并不像开发GUI程序那个年代那么强烈了。但鉴于微软自身产品体系的庞大,VS始终在市场中占有一席之地。

而近些年,一些跨平台、轻量级、插件结构、支持智能感知、可随意定制的文本编辑器的出现,比如:Sublime TextAtom等让开发人员喜不自禁。这些编辑器并非定位于IDE,但功能又不输给IDE很多,尤其在支持编码、调试这些环节,它们完全可以与专业IDE媲美,但资源消耗却是像Visual Studio、Eclipse这样大而全的IDE所无法匹敌的。而Visual Studio Code恰是微软在这方面的一个尝试,也是微软最新公司战略的体现之一:拥抱所有开发者(不仅仅是Windows上的哦)。

二、VSCode安装

VSCode发布于2015年4月的Build大会上。发布后,迅速得到开发者响应,大家普遍反映:VSCode性能不错、关注细节、体验良好,虽然当时VSCode的插件还不算丰富。一年多过去后,VSCode已经演化到了1.8.1版本(截至2016年12月末),支持所有主流编程语言的开发,配套的插件也十分丰富了。VSCode的安装简单的很,这一向都是微软的强项,你可以在其官方站上下载到各个平台的安装包(Linux平台也有.deb/.rpm两种包格式供选择,并提供32bit和64bit两种版本)。下载后安装即可。

1、VSCode配置和数据存储路径

VSCode安装后,一般不必关心其配置和数据存储路径的位置。但作为有一些Geek精神的developer来说,弄清楚其安装和配置的来龙去脉还是很有意义的。

在Mac上:

VSCode存储运行数据和配置文件的目录在:~/Library/Application Support/Code下:

 ~/Library/Application Support/Code]$ls
Backups/        CachedData/        Cookies-journal        Local Storage/        User/
Cache/            Cookies            GPUCache/        Preferences        storage.json

$ls User
keybindings.json    locale.json        settings.json        snippets/        workspaceStorage/

在Ubuntu中:

VSCode存储运行数据和配置文件的目录在~/.config/Code下面:

~/.config/Code$ ls
Backups  Cache  CachedData  Cookies  Cookies-journal  GPUCache  Local Storage  storage.json  User

至于Windows平台,请自行探索^_^。

2、启动方式

VSCode有两种启动方式:桌面启动和命令行启动。桌面启动自不必说了。命令行启动的示例如下:

$ code main.go

code命令会打开一个VSCode窗口并加载命令参数中的文件内容,这里是main.go。

三、VSCode的配置

一般来说,VSCode启动即可用了。但要想发挥出VSCode的能量,我们必须对其进行一番配置。VSCode的配置有几十上百项,这里无法全覆盖,仅说明一下我个人比较关注的。

1、安装插件

像VSCode这种小清新文本编辑器要想对编程语言有很好的支持,必须安装相应语言的插件。以Go为例,我们至少要安装vscode-go插件。vscode-go之于VSCode,就好比vim-go之于VIM。并且和vim-go类似,vscode-go实现的各种Features也是依赖诸多已存在的Go周边工具,包括:

gocode: go get -u -v github.com/nsf/gocode
godef: go get -u -v github.com/rogpeppe/godef
gogetdoc: go get -u -v github.com/zmb3/gogetdoc
golint: go get -u -v github.com/golang/lint/golint
go-outline: go get -u -v github.com/lukehoban/go-outline
goreturns: go get -u -v sourcegraph.com/sqs/goreturns
gorename: go get -u -v golang.org/x/tools/cmd/gorename
gopkgs: go get -u -v github.com/tpng/gopkgs
go-symbols: go get -u -v github.com/newhook/go-symbols
guru: go get -u -v golang.org/x/tools/cmd/guru
gotests: go get -u -v github.com/cweill/gotests/...

因此,要想实现vscode-go官网页面中demo中哪些神奇的Feature,你必须将上面的这些依赖工具逐一安装成功。如果缺少一个依赖工具,VSCode会在窗口右下角的状态栏里显示:“Analysis Tools Missing”字样,以提示你安装这些工具。

VSCode当然也支持Vim-mode的编辑模式,如果你也和我一样,喜欢用vim-mode在VSCode中进行编辑,可以安装VSCodeVim插件

VSCode的插件安装方式分为两种:在线安装和VSIX方式安装。

在线安装,顾名思义,即在VSCode的窗口左侧边栏中点击“Extensions”按钮,在打开的Extensions搜索框中搜索你想要的插件名称,或者选择预制的条件获得插件信息。选中你要安装的插件,点击“Install”按钮即可完成安装。

VSIX安装:即到插件官网将插件文件下载到本地(插件安装文件一般以.vsix或.zip结尾),在窗口中选择:”Install from VSIX…”,选择你下载的插件文件即可。

安装后的插件都被放在~/.vscode/extensions目录下(mac和linux)。

2、更改语言设置

VSCode在初次启动时会判断当前系统语言,并以相应的语言作为默认窗口显示语言。比如:我的是中文OS X系统,那么默认VSCode的窗口文字都是中文。如果我要将其改为英文,应该如何操作呢?

F1登场!这里的F1可不是赛车比赛,而是快捷键F1,估计也是整个VSCode最常用的快捷键之一了。敲击F1后,VSCode会显示其“Command Palette”输入框,这里面包含了当前VSCode可以执行的所有操作命令,支持Search。我们输入”language”,在搜索结果中选择“Configure Language”,VSCode打开一个新的编辑窗口,加载~/Library/Application Support/Code/User/locale.json文件:

{
    // 定义 VSCode 的显示语言。
    // 请参阅 https://go.microsoft.com/fwlink/?LinkId=761051,了解支持的语言列表。
    // 要更改值需要重启 VSCode。
    "locale": "zh-cn"
}

当前语言为中文,如果我们要将其改为英文,则修改该文件中的”locale”项:

{
    // 定义 VSCode 的显示语言。
    // 请参阅 https://go.microsoft.com/fwlink/?LinkId=761051,了解支持的语言列表。
    // 要更改值需要重启 VSCode。
    "locale": "en-US"
}

保存,重启VSCode。再次启动的VSCode将会以英文界面示人了。

3、User Settings和Workspace Settings

UserSettings是一种“全局”设置,而Workspace Settings则顾名思义,是一种针对一个特定目录或project的设置。

UserSettings设置后的数据保存在~/Library/Application Support/Code下(以mac为例),而Workspace Setting设置后的数据则保存在某个项目特定目录下的.vscode目录下。

在菜单栏,选择【Preferences -> User Settings】可以打开~/Library/Application Support/Code/User/settings.json文件。默认情况下,该文件为空。VSCode采用默认设置。如果你要个性化设置,那么可将对应的配置项copy一份到settings.json中,并赋予其新值,保存即可。新值将覆盖默认值。以字体大小为例,我们将默认的editor.fontSize 12改为10:

// Place your settings in this file to overwrite the default settings
{
    "editor.fontSize": 10,
}

保存后,可以看到窗口中所有文字的Size都变小了。

在菜单栏,选择【Preferences -> Workspace Settings】可打开当前工作目录下的.vscode的settings.json文件,其工作原理和配置方法与User Settings一样,只是生效范围仅限于该工作区范畴。

4、Color Theme

VSCode内置了主流的配色方案,比如:monokai、solarized dark/light等。F1,输入”color”搜索,选择:“Perefences: Color Theme”(在MAC上也可以用cmd+k, cmd+t打开),在下拉列表中选择你喜欢的配色Theme即可,即可生效。

四、vscode-go的使用

前面说过,和vim-go一样,vscode-go插件实现了Go编码中需要的各种功能:自动format、自动增删import、build on save、lint on save、定义跳转、原型信息快速提示、自动补全、code snippets等。另外它通过带颜色的波浪线提示代码问题(虽然有时候反应有点慢),包括语法问题、不符合idiomatic go规则的问题(比如appId这个命名,它会建议你改为appID)等。

code snippets非常好用,内置的code snippets在~/.vscode/extensions/lukehoban.Go-0.6.51/snippets/go.json中可以找到,类似这样的定义:

//~/.vscode/extensions/lukehoban.Go-0.6.51/snippets/go.json
{
        ".source.go": {
                "single import": {
                        "prefix": "im",
                        "body": "import \"${1:package}\""
                },
                "multiple imports": {
                        "prefix": "ims",
                        "body": "import (\n\t\"${1:package}\"\n)"
                },
                "single constant": {
                        "prefix": "co",
                        "body": "const ${1:name} = ${2:value}"
                },
                "multiple constants": {
                        "prefix": "cos",
                        "body": "const (\n\t${1:name} = ${2:value}\n)"
                },
                "type interface declaration": {
                        "prefix": "tyi",
                        "body": "type ${1:name} interface {\n\t$0\n}"
                },
                "type struct declaration": {
                        "prefix": "tys",
                        "body": "type ${1:name} struct {\n\t$0\n}"
                },
                "package main and main function": {
                        "prefix": "pkgm",
                        "body": "package main\n\nfunc main() {\n\t$0\n}"
                },
... ...

敲入”prefix”的值,比如”ims”,输入tab,vscode-go将为你展开为:

import (
    "package"
)

在使用vscode时遇到过一次代码自动补全“失灵”的问题。vscode-go只会提示:”PANIC,PANIC,PANIC”。经查,这个是gocode daemon的问题,我的解决方法是:

gocode close //关闭gocode daemon
gocode -s &  //重启之。

五、小结

在诸多轻量级编辑器中,我还是比较看好vscode的,毕竟其背后有着Microsoft积淀多年的IDE产品开发经验。并且和Microsoft以往产品最大的不同就是其是开源项目。

关于Vscode的使用和奇技淫巧可以参见其官方的这篇文档“VS Code Tips and Tricks”。

关于Vscode的各种周边工具和资料列表,请参考Awesome-vscode项目

快捷键往往是开发人员的最爱,VSCode官方制作了三个平台的VSCode的快捷键worksheet:

https://code.visualstudio.com/shortcuts/keyboard-shortcuts-windows.pdf

https://code.visualstudio.com/shortcuts/keyboard-shortcuts-macos.pdf

https://code.visualstudio.com/shortcuts/keyboard-shortcuts-linux.pdf

VSCode还在快速发展,离完善还有不小提升空间。比如:在使用过程中也发现了VSCode 窗口无响应或代码编辑错乱之情况。不过作为Go编码的一个辅助编辑器,VSCode还是完全胜任和超出预期的。

论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。这里就不赘述了。




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

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

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


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

比特币:


以太币:


如果您喜欢通过微信App浏览本站内容,可以扫描下方二维码,订阅本站官方微信订阅号“iamtonybai”;点击二维码,可直达本人官方微博主页^_^:



本站Powered by Digital Ocean VPS。

选择Digital Ocean VPS主机,即可获得10美元现金充值,可免费使用两个月哟!

著名主机提供商Linode 10$优惠码:linode10,在这里注册即可免费获得。

阿里云推荐码:1WFZ0V立享9折!

View Tony Bai's profile on LinkedIn


文章

评论

  • 正在加载...

分类

标签

归档











更多