标签 标准库 下的文章

写Go代码时遇到的那些问题[第3期]

我有一个习惯,那就是随时记录下编程过程中遇到的问题(包括问题现场、问题起因以及对问题的分析),并喜欢阶段性的对一段时间内的编码过程的得与失进行回顾和总结。内容可以包括:对编程语法的新认知、遇坑填坑的经历、一些让自己豁然开朗的小tip/小实践等。记录和总结的多了,感觉有价值的,就成文发在博客上的;一些小的点,或是还没有想清楚的事情,或思路没法结构化统一的,就放在资料库里备用。“写Go代码时遇到的那些问题”这个系列也是基于这个思路做的。

在这一篇中,我把“所遇到的问题”划分为三类:语言类、库与工具类、实践类,这样应该更便于大家分类阅读和理解。另外借这篇文章,我们先来看一下Go语言当前的State,资料来自于twitter、redditgolang-dev forum、github上golang项目的issue/cl以及各种gophercon的talk资料。

零. Go语言当前状态

1. vgo

Go 1.10在中国农历春节期间正式发布。随后Go team进入了Go 1.11的开发周期

在2017年的Go语言用户调查报告结果中,缺少良好的包管理工具以及Generics依然是Gopher面临的最为棘手的挑战和难题的Top2,Go team也终于开始认真对待这两个问题了,尤其是包依赖管理的问题。在今年2月末,Russ Cox在自己的博客上连续发表了七篇博文,详细阐述了vgo – 带版本感知和支持的Go命令行工具的设计思路和实现方案,并在3月末正式提交了”versioned-go proposal“。

目前相对成熟的包管理方案是:

"语义化版本"
+manifest文件(手工维护的依赖约束描述文件)
+lock文件(工具自动生成的传递依赖描述文件)
+版本选择引擎工具(比如dep中的gps - Go Packaging Solver)

与之相比,vgo既有继承,更有创新。继承的是对语义化版本的支持,创新的则是semantic import versioning最小版本选择minimal version selection等新机制,不变的则是对Go1语法的兼容。按照Russ Cox的计划,Go 1.11很可能会提供一个试验性的vgo实现(当然vgo所呈现的形式估计是merge到go tools中),让广大gopher试用和反馈,然后会像vendor机制那样,在后续Go版本中逐渐成为默认选项。

2. wasm porting

知名开源项目gopherjs的作者Richard Musiol上个月提交了一个proposal: WebAssembly architecture for Go,主旨在于让Gopher也可以用Go编写前端代码,让Go编写的代码可以在浏览器中运行。当然这并不是真的让Go能像js那样直接运行于浏览器或nodejs上,而是将Go编译为WebAssembly,wasm中间字节码,再在浏览器或nodejs初始化的运行环境中运行。这里根据自己的理解粗略画了一幅二进制机器码的go app与中间码的wasm的运行层次对比图,希望对大家有用:

img{512x368}

wasm porting已经完成了第一次commit ,很大可能会随着go1.11一并发布第一个版本。

3. 非协作式的goroutine抢占式调度

当前goroutine“抢占式”调度依靠的是compiler在函数中自动插入的“cooperative preemption point”来实现的,但这种方式在使用过程中依然有各种各样的问题,比如:检查点的性能损耗、诡异的全面延迟问题以及调试上的困难。近期负责go runtime gc设计与实现的Austin Clements提出了一个proposal:non-cooperative goroutine preemption ,该proposal将去除cooperative preemption point,而改为利用构建和记录每条指令的stack和register map的方式实现goroutine的抢占, 该proposal预计将在go 1.12中实现。

4. Go的历史与未来

GopherConRu 2018大会上,来自Go team的核心成员Brad Fitzpatrick做了“Go的历史与未来”的主题演讲 ,Bradfitz“爆料”了关于Go2的几个可能,考虑到Bradfitz在Go team中的位置,这些可能性还是具有很大可信度的:

1). 绝不像Perl6和Python3那样分裂社区
2). Go1的包可以import Go2的package
3). Go2很可能加入Generics,Ian Lance Taylor应该在主导该Proposal
4). Go2在error handling方面会有改进,但不会是try--catch那种形式
5). 相比于Go1,Go2仅会在1-3个方面做出重大变化
6). Go2可能会有一个新的标准库,并且该标准库会比现有的标准库更小,很多功能放到标准库外面
7). 但Go2会在标准库外面给出最流行、推荐的、可能认证的常用包列表,这些在标准库外面的包可以持续更新,而不像那些在标准库中的包,只能半年更新一次。

一. 语言篇

1. len(channel)的使用

len是Go语言的一个built-in函数,它支持接受array、slice、map、string、channel类型的参数,并返回对应类型的”长度” – 一个整型值:

len(s)   

如果s是string,len(s)返回字符串中的字节个数
如何s是[n]T, *[n]T的数组类型,len(s)返回数组的长度n
如果s是[]T的Slice类型,len(s)返回slice的当前长度
如果s是map[K]T的map类型,len(s)返回map中的已定义的key的个数
如果s是chan T类型,那么len(s)返回当前在buffered channel中排队(尚未读取)的元素个数

不过我们在代码经常见到的是len函数针对数组、slice、string类型的调用,而len与channel的联合使用却很少。那是不是说len(channel)就不可用了呢?我们先来看看len(channel)的语义。

  • 当channel为unbuffered channel时,len(channel)总是返回0;
  • 当channel为buffered channel时,len(channel)返回当前channel中尚未被读取的元素个数。

这样一来,所谓len(channel)中的channel就是针对buffered channel。len(channel)从语义上来说一般会被用来做“判满”、”判有”和”判空”逻辑:

// 判空

if len(channel) == 0 {
    // 这时:channel 空了 ?
}

// 判有

if len(channel) > 0 {
    // 这时:channel 有数据了 ?
}

// 判满
if len(channel) == cap(channel) {
    // 这时:   channel 满了 ?
}

大家看到了,我在上面代码中注释:“空了”、“有数据了”和“满了”的后面打上了问号!channel多用于多个goroutine间的通讯,一旦多个goroutine共同读写channel,len(channel)就会在多个goroutine间形成”竞态条件”,单存的依靠len(channel)来判断队列状态,不能保证在后续真正读写channel的时候channel状态是不变的。以判空为例:

img{512x368}

从上图可以看到,当goroutine1使用len(channel)判空后,便尝试从channel中读取数据。但在真正从Channel读数据前,另外一个goroutine2已经将数据读了出去,goroutine1后面的读取将阻塞在channel上,导致后面逻辑的失效。因此,为了不阻塞在channel上,常见的方法是将“判空与读取”放在一起做、将”判满与写入”一起做,通过select实现操作的“事务性”:

//writing-go-code-issues/3rd-issue/channel_len.go/channel_len.go.go
func readFromChan(ch <-chan int) (int, bool) {
    select {
    case i := <-ch:
        return i, true
    default:
        return 0, false // channel is empty
    }
}

func writeToChan(ch chan<- int, i int) bool {
    select {
    case ch <- i:
        return true
    default:
        return false // channel is full
    }
}

我们看到由于用到了Select-default的trick,当channel空的时候,readFromChan不会阻塞;当channel满的时候,writeToChan也不会阻塞。这种方法也许适合大多数的场合,但是这种方法有一个“问题”,那就是“改变了channel的状态”:读出了一个元素或写入了一个元素。有些时候,我们不想这么做,我们想在不改变channel状态下单纯地侦测channel状态!很遗憾,目前没有哪种方法可以适用于所有场合。但是在特定的场景下,我们可以用len(channel)实现。比如下面这个场景:

img{512x368}

这是一个“多producer + 1 consumer”的场景。controller是一个总控协程,初始情况下,它来判断channel中是否有消息。如果有消息,它本身不消费“消息”,而是创建一个consumer来消费消息,直到consumer因某种情况退出,控制权再回到controller,controller不会立即创建new consumer,而是等待channel下一次有消息时才创建。在这样一个场景中,我们就可以使用len(channel)来判断是否有消息。

2. 时间的格式化输出

时间的格式化输出是日常编程中经常遇到的“题目”。以前使用C语言编程时,用的是strftime。我们来回忆一下c的代码:

// writing-go-code-issues/3rd-issue/time-format/strftime_in_c.c
#include <stdio.h>
#include <time.h>

int main() {
        time_t now = time(NULL);

        struct tm *localTm;
        localTm = localtime(&now);

        char strTime[100];
        strftime(strTime, sizeof(strTime),  "%Y-%m-%d %H:%M:%S", localTm);
        printf("%s\n", strTime);

        return 0;
}

这段c代码输出结果是:

2018-04-04 16:07:00

我们看到strftime采用“字符化”的占位符(诸如:%Y、%m等)“拼”出时间的目标输出格式布局(如:”%Y-%m-%d %H:%M:%S”),这种方式不仅在C中采用,很多其他主流编程语言也采用了该方案,比如:shell、pythonrubyjava等,这似乎已经成为了各种编程语言在时间格式化输出的标准。这些占位符对应的字符(比如Y、M、H)是对应英文单词的头母,因此相对来说较为容易记忆。

但是如果你在Go中使用strftime的这套“标准”,看到输出结果的那一刻,你肯定要“骂娘”!

// writing-go-code-issues/3rd-issue/time-format/timeformat_in_c_way.go
package main

import (
    "fmt"
    "time"
)

func main() {
    fmt.Println(time.Now().Format("%Y-%m-%d %H:%M:%S"))
}

上述go代码输出结果如下:

%Y-%m-%d %H:%M:%S

Go居然将“时间格式占位符字符串”原封不动的输出了!

这是因为Go另辟了蹊径,采用了不同于strftime的时间格式化输出的方案。Go的设计者主要出于这样的考虑:虽然strftime的单个占位符使用了对应单词的首字母的形式,但是但真正写起代码来,不打开strftime函数的manual或查看网页版的strftime助记符说明,很难真的拼出一个复杂的时间格式。并且对于一个”%Y-%m-%d %H:%M:%S”的格式串,不对照文档,很难在大脑中准确给出格式化后的时间结果,比如%Y和%y有何不同、%M和%m又有何差别呢?

Go语言采用了更为直观的“参考时间(reference time)”替代strftime的各种标准占位符,使用“参考时间”构造出来的“时间格式串”与最终输出串是“一模一样”的,这就省去了程序员再次在大脑中对格式串进行解析的过程:

格式串:"2006年01月02日 15时04分05秒"

=>

输出结果:2018年04月04日 18时13分08秒

标准的参考时间如下:

2006-01-02 15:04:05 PM -07:00 Jan Mon MST

这个绝对时间本身并没有什么实际意义,仅是出于“好记”的考虑,我们将这个参考时间换为另外一种时间输出格式:

01/02 03:04:05PM '06 -0700

我们看出Go设计者的“用心良苦”,这个时间其实恰好是将助记符从小到大排序(从01到07)的结果,可以理解为:01对应的是%M, 02对应的是%d等等。下面这幅图形象地展示了“参考时间”、“格式串”与最终格式化的输出结果之间的关系:

img{512x368}

就我个人使用go的经历来看,我在做时间格式化输出时,尤其是构建略微复杂的时间格式输出时,也还是要go doc time包或打开time包的web手册的。从社区的反馈来看,很多Gopher也都有类似经历,尤其是那些已经用惯了strftime格式的gopher。甚至有人专门做了“Fucking Go Date Format”页面,来帮助自动将strftime使用的格式转换为go time的格式。

下面这幅cheatsheet也能提供一些帮助(由writing-go-code-issues/3rd-issue/time-format/timeformat_cheatsheet.go输出生成):

img{512x368}

二. 库与工具篇

1. golang.org/x/text/encoding/unicode遇坑一则

gocmpp这个项目中,我用到了unicode字符集转换:将utf8转换为ucs2(utf16)、ucs2转换为utf8、utf8转为GB18030等。这些转换功能,我是借助golang.org/x/text这个项目下的encoding/unicode和transform实现的。x/text是golang官方维护的text处理的工具包,其中包含了对unicode字符集的相关操作。

要实现一个utf8到ucs2(utf16)的字符集转换,只需像如下这样实现即可(这也是我的最初实现):

func Utf8ToUcs2(in string) (string, error) {
    if !utf8.ValidString(in) {
        return "", ErrInvalidUtf8Rune
    }

    r := bytes.NewReader([]byte(in))

    //UTF-16 bigendian, no-bom
    t := transform.NewReader(r, unicode.All[1].NewEncoder())
    out, err := ioutil.ReadAll(t)
    if err != nil {
        return "", err
    }
    return string(out), nil
}

这里要注意是unicode.All这个切片保存着UTF-16的所有格式:

var All = []encoding.Encoding{
    UTF16(BigEndian, UseBOM),
    UTF16(BigEndian, IgnoreBOM),
    UTF16(LittleEndian, IgnoreBOM),
}

这里我最初我用的是All[1],即UTF16(BigEndian, IgnoreBOM),一切都是正常的。

但就在年前,我将text项目更新到最新版本,然后发现单元测试无法通过:

--- FAIL: TestUtf8ToUcs2 (0.00s)
    utils_test.go:58: The first char is fe, not equal to expected 6c
FAIL
FAIL    github.com/bigwhite/gocmpp/utils    0.008s

经查找发现:text项目的golang.org/x/text/encoding/unicode包做了不兼容的修改,上面那个unicode.All切片变成了下面这个样子:

// All lists a configuration for each IANA-defined UTF-16 variant.
var All = []encoding.Encoding{
    UTF8,
    UTF16(BigEndian, UseBOM),
    UTF16(BigEndian, IgnoreBOM),
    UTF16(LittleEndian, IgnoreBOM),
}

All切片在最前面插入了一个UTF8元素,这样导致我的代码中原本使用的 UTF16(BigEndian, IgnoreBOM)变成了UTF16(BigEndian, UseBOM),test不过也就情有可原了。

如何改呢?这回儿我直接使用UTF16(BigEndian, IgnoreBOM),而不再使用All切片了:

func Utf8ToUcs2(in string) (string, error) {
    if !utf8.ValidString(in) {
        return "", ErrInvalidUtf8Rune
    }

    r := bytes.NewReader([]byte(in))
    //UTF-16 bigendian, no-bom
    t := transform.NewReader(r,
            unicode.UTF16(unicode.BigEndian, unicode.IgnoreBOM).NewEncoder())
    out, err := ioutil.ReadAll(t)
    if err != nil {
        return "", err
    }
    return string(out), nil
}

这样即便All切片再有什么变动,我的代码也不会受到什么影响了。

2. logrus的非结构化日志定制输出

在该系列的第一篇文章中,我提到过使用logrus+lumberjack来实现支持rotate的logging。

默认情况下日志的输出格式是这样的(writing-go-code-issues/3rd-issue/logrus/logrus2lumberjack_default.go):

time="2018-04-05T06:08:53+08:00" level=info msg="logrus log to lumberjack in normal text formatter"

这样相对结构化的日志比较适合后续的集中日志分析。但是日志携带的“元信息(time、level、msg)”过多,并不是所有场合都倾向于这种日志,于是我们期望以普通的非结构化的日志输出,我们定制formatter:

// writing-go-code-issues/3rd-issue/logrus/logrus2lumberjack.go
func main() {
    customFormatter := &logrus.TextFormatter{
        FullTimestamp:   true,
        TimestampFormat: "2006-01-02 15:04:05",
    }
    logger := logrus.New()
    logger.Formatter = customFormatter

    rotateLogger := &lumberjack.Logger{
        Filename: "./foo.log",
    }
    logger.Out = rotateLogger
    logger.Info("logrus log to lumberjack in normal text formatter")
}

我们使用textformatter,并定制了时间戳的格式,输出结果如下:

time="2018-04-05 06:22:57" level=info msg="logrus log to lumberjack in normal text formatter"

日志仍然不是我们想要的那种。但同样的customFormatter如果输出到terminal,结果却是我们想要的:

//writing-go-code-issues/3rd-issue/logrus/logrus2tty.go

INFO[2018-04-05 06:26:16] logrus log to tty in normal text formatter

到底如何设置TextFormatter的属性才能让我们输出到lumberjack中的日志格式是我们想要的这种呢?无奈下只能挖logrus的源码了,我们找到了这段代码:

//github.com/sirupsen/logrus/text_formatter.go

// Format renders a single log entry
func (f *TextFormatter) Format(entry *Entry) ([]byte, error) {
    ... ...
    isColored := (f.ForceColors || f.isTerminal) && !f.DisableColors

    timestampFormat := f.TimestampFormat
    if timestampFormat == "" {
        timestampFormat = defaultTimestampFormat
    }
    if isColored {
        f.printColored(b, entry, keys, timestampFormat)
    } else {
        if !f.DisableTimestamp {
            f.appendKeyValue(b, "time", entry.Time.Format(timestampFormat))
        }
        f.appendKeyValue(b, "level", entry.Level.String())
        if entry.Message != "" {
            f.appendKeyValue(b, "msg", entry.Message)
        }
        for _, key := range keys {
            f.appendKeyValue(b, key, entry.Data[key])
        }
    }

    b.WriteByte('\n')
    return b.Bytes(), nil
}

我们看到如果isColored为false,输出的就是带有time, msg, level的结构化日志;只有isColored为true才能输出我们想要的普通日志。isColored的值与三个属性有关:ForceColors 、isTerminal和DisableColors。我们按照让isColored为true的条件组合重新设置一下这三个属性,因为输出到file,因此isTerminal自动为false。

//writing-go-code-issues/3rd-issue/logrus/logrus2lumberjack_normal.go
func main() {
    //    isColored := (f.ForceColors || f.isTerminal) && !f.DisableColors
    customFormatter := &logrus.TextFormatter{
        FullTimestamp:   true,
        TimestampFormat: "2006-01-02 15:04:05",
        ForceColors:     true,
    }
    logger := logrus.New()
    logger.Formatter = customFormatter

    rotateLogger := &lumberjack.Logger{
        Filename: "./foo.log",
    }
    logger.Out = rotateLogger
    logger.Info("logrus log to lumberjack in normal text formatter")
}

我们设置ForceColors为true后,在foo.log中得到了我们期望的输出结果:

INFO[2018-04-05 06:33:22] logrus log to lumberjack in normal text formatter

三. 实践篇

1. 说说网络数据读取timeout的处理 – 以SetReadDeadline为例

Go天生适合于网络编程,但网络编程的复杂性也是有目共睹的、要写出稳定、高效的网络端程序,需要的考虑的因素有很多。比如其中之一的:从socket读取数据超时的问题。

Go语言标准网络库并没有实现epoll实现的那样的“idle timeout”,而是提供了Deadline机制,我们用一副图来对比一下两个机制的不同:

img{512x368}

看上图a)和b)展示了”idle timeout”机制,所谓idle timeout就是指这个timeout是真正在没有data ready的情况的timeout(如图中a),如果有数据ready可读(如图中b),那么timeout机制暂停,直到数据读完后,再次进入数据等待的时候,idle timeout再次启动。

而deadline(以read deadline为例)机制,则是无论是否有数据ready以及数据读取活动,都会在到达时间(deadline)后的再次read时返回timeout error,并且后续的所有network read operation也都会返回timeout(如图中d),除非重新调用SetReadDeadline(time.Time{})取消Deadline或在再次读取动作前重新重新设定deadline实现续时的目的。Go网络编程一般是“阻塞模型”,那为什么还要有SetReadDeadline呢,这是因为有时候,我们要给调用者“感知”其他“异常情况”的机会,比如是否收到了main goroutine发送过来的退出通知信息

Deadline机制在使用起来很容易出错,这里列举两个曾经遇到的出错状况:

a) 以为SetReadDeadline后,后续每次Read都可能实现idle timeout

img{512x368}

在上图中,我们看到这个流程是读取一个完整业务包的过程,业务包的读取使用了三次Read调用,但是只在第一次Read前调用了SetReadDeadline。这种使用方式仅仅在Read A时实现了足额的“idle timeout”,且仅当A数据始终未ready时会timeout;一旦A数据ready并已经被Read,当Read B和Read C时,如果还期望足额的“idle timeout”那就误解了SetReadDeadline的真正含义了。因此要想在每次Read时都实现“足额的idle timeout”,需要在每次Read前都重新设定deadline。

b) 一个完整“业务包”分多次读取的异常情况的处理

img{512x368}

在这幅图中,每个Read前都重新设定了deadline,那么这样就一定ok了么?对于在一个过程中读取一个“完整业务包”的业务逻辑来说,我们还要考虑对每次读取异常情况的处理,尤其是timeout发生。在该例子中,有三个Read位置需要考虑异常处理。

如果Read A始终没有读到数据,deadline到期,返回timeout,这里是最容易处理的,因为此时前一个完整数据包已经被读完,新的完整数据包还没有到来,外层控制逻辑收到timeout后,重启再次启动该读流程即可。

如果Read B或Read C处没有读到数据,deadline到期,这时异常处理就棘手一些,因为一个完整数据包的部分数据(A)已经从流中被读出,剩余的数据并不是一个完整的业务数据包,不能简单地再在外层控制逻辑中重新启动该过程。我们要么在Read B或Read C处尝试多次重读,直到将完整数据包读取完整后返回;要么认为在B或C处出现timeout是不合理的,返回区别于A处的错误码给外层控制逻辑,让外层逻辑决定是否是连接存在异常。

注:本文所涉及的示例代码,请到这里下载。


微博:@tonybai_cn
微信公众号:iamtonybai
github.com: https://github.com/bigwhite

微信赞赏:
img{512x368}

也谈Go的可移植性

Go有很多优点,比如:简单原生支持并发等,而不错的可移植性也是Go被广大程序员接纳的重要因素之一。但你知道为什么Go语言拥有很好的平台可移植性吗?本着“知其然,亦要知其所以然”的精神,本文我们就来探究一下Go良好可移植性背后的原理。

一、Go的可移植性

说到一门编程语言可移植性,我们一般从下面两个方面考量:

  • 语言自身被移植到不同平台的容易程度;
  • 通过这种语言编译出来的应用程序对平台的适应性。

Go 1.7及以后版本中,我们可以通过下面命令查看Go支持OS和平台列表:

$go tool dist list
android/386
android/amd64
android/arm
android/arm64
darwin/386
darwin/amd64
darwin/arm
darwin/arm64
dragonfly/amd64
freebsd/386
freebsd/amd64
freebsd/arm
linux/386
linux/amd64
linux/arm
linux/arm64
linux/mips
linux/mips64
linux/mips64le
linux/mipsle
linux/ppc64
linux/ppc64le
linux/s390x
nacl/386
nacl/amd64p32
nacl/arm
netbsd/386
netbsd/amd64
netbsd/arm
openbsd/386
openbsd/amd64
openbsd/arm
plan9/386
plan9/amd64
plan9/arm
solaris/amd64
windows/386
windows/amd64

从上述列表我们可以看出:从linux/arm64的嵌入式系统到linux/s390x的大型机系统,再到Windows、linux和darwin(mac)这样的主流操作系统、amd64、386这样的主流处理器体系,Go对各种平台和操作系统的支持不可谓不广泛。

Go官方似乎没有给出明确的porting guide,关于将Go语言porting到其他平台上的内容更多是在golang-dev这样的小圈子中讨论的事情。但就Go语言这么短的时间就能很好的支持这么多平台来看,Go的porting还是相对easy的。从个人对Go的了解来看,这一定程度上得益于Go独立实现了runtime。

img{512x368}

runtime是支撑程序运行的基础。我们最熟悉的莫过于libc(C运行时),它是目前主流操作系统上应用最普遍的运行时,通常以动态链接库的形式(比如:/lib/x86_64-linux-gnu/libc.so.6)随着系统一并发布,它的功能大致有如下几个:

  • 提供基础库函数调用,比如:strncpy
  • 封装syscall(注:syscall是操作系统提供的API口,当用户层进行系统调用时,代码会trap(陷入)到内核层面执行),并提供同语言的库函数调用,比如:malloc、fread等;
  • 提供程序启动入口函数,比如:linux下的__libc_start_main。

libc等c runtime lib是很早以前就已经实现的了,甚至有些老旧的libc还是单线程的。一些从事c/c++开发多年的程序员早年估计都有过这样的经历:那就是链接runtime库时甚至需要选择链接支持多线程的库还是只支持单线程的库。除此之外,c runtime的版本也参差不齐。这样的c runtime状况完全不能满足go语言自身的需求;另外Go的目标之一是原生支持并发,并使用goroutine模型,c runtime对此是无能为力的,因为c runtime本身是基于线程模型的。综合以上因素,Go自己实现了runtime,并封装了syscall,为不同平台上的go user level代码提供封装完成的、统一的go标准库;同时Go runtime实现了对goroutine模型的支持。

独立实现的go runtime层将Go user-level code与OS syscall解耦,把Go porting到一个新平台时,将runtime与新平台的syscall对接即可(当然porting工作不仅仅只有这些);同时,runtime层的实现基本摆脱了Go程序对libc的依赖,这样静态编译的Go程序具有很好的平台适应性。比如:一个compiled for linux amd64的Go程序可以很好的运行于不同linux发行版(centos、ubuntu)下。

以下测试试验环境为:darwin amd64 Go 1.8

二、默认”静态链接”的Go程序

我们先来写两个程序:hello.c和hello.go,它们完成的功能都差不多,在stdout上输出一行文字:

//hello.c
#include <stdio.h>

int main() {
        printf("%s\n", "hello, portable c!");
        return 0;
}

//hello.go
package main

import "fmt"

func main() {
    fmt.Println("hello, portable go!")
}

我们采用“默认”方式分别编译以下两个程序:

$cc -o helloc hello.c
$go build -o hellogo hello.go

$ls -l
-rwxr-xr-x    1 tony  staff     8496  6 27 14:18 helloc*
-rwxr-xr-x    1 tony  staff  1628192  6 27 14:18 hellogo*

从编译后的两个文件helloc和hellogo的size上我们可以看到hellogo相比于helloc简直就是“巨人”般的存在,其size近helloc的200倍。略微学过一些Go的人都知道,这是因为hellogo中包含了必需的go runtime。我们通过otool工具(linux上可以用ldd)查看一下两个文件的对外部动态库的依赖情况:

$otool -L helloc
helloc:
    /usr/lib/libSystem.B.dylib (compatibility version 1.0.0, current version 1197.1.1)
$otool -L hellogo
hellogo:

通过otool输出,我们可以看到hellogo并不依赖任何外部库,我们将hellog这个二进制文件copy到任何一个mac amd64的平台上,均可以运行起来。而helloc则依赖外部的动态库:/usr/lib/libSystem.B.dylib,而libSystem.B.dylib这个动态库还有其他依赖。我们通过nm工具可以查看到helloc具体是哪个函数符号需要由外部动态库提供:

$nm helloc
0000000100000000 T __mh_execute_header
0000000100000f30 T _main
                 U _printf
                 U dyld_stub_binder

可以看到:_printf和dyld_stub_binder两个符号是未定义的(对应的前缀符号是U)。如果对hellog使用nm,你会看到大量符号输出,但没有未定义的符号。

$nm hellogo
00000000010bb278 s $f64.3eb0000000000000
00000000010bb280 s $f64.3fd0000000000000
00000000010bb288 s $f64.3fe0000000000000
00000000010bb290 s $f64.3fee666666666666
00000000010bb298 s $f64.3ff0000000000000
00000000010bb2a0 s $f64.4014000000000000
00000000010bb2a8 s $f64.4024000000000000
00000000010bb2b0 s $f64.403a000000000000
00000000010bb2b8 s $f64.4059000000000000
00000000010bb2c0 s $f64.43e0000000000000
00000000010bb2c8 s $f64.8000000000000000
00000000010bb2d0 s $f64.bfe62e42fefa39ef
000000000110af40 b __cgo_init
000000000110af48 b __cgo_notify_runtime_init_done
000000000110af50 b __cgo_thread_start
000000000104d1e0 t __rt0_amd64_darwin
000000000104a0f0 t _callRet
000000000104b580 t _gosave
000000000104d200 T _main
00000000010bbb20 s _masks
000000000104d370 t _nanotime
000000000104b7a0 t _setg_gcc
00000000010bbc20 s _shifts
0000000001051840 t errors.(*errorString).Error
00000000010517a0 t errors.New
.... ...
0000000001065160 t type..hash.time.Time
0000000001064f70 t type..hash.time.zone
00000000010650a0 t type..hash.time.zoneTrans
0000000001051860 t unicode/utf8.DecodeRuneInString
0000000001051a80 t unicode/utf8.EncodeRune
0000000001051bd0 t unicode/utf8.RuneCount
0000000001051d10 t unicode/utf8.RuneCountInString
0000000001107080 s unicode/utf8.acceptRanges
00000000011079e0 s unicode/utf8.first

$nm hellogo|grep " U "

Go将所有运行需要的函数代码都放到了hellogo中,这就是所谓的“静态链接”。是不是所有情况下,Go都不会依赖外部动态共享库呢?我们来看看下面这段代码:

//server.go
package main

import (
    "log"
    "net/http"
    "os"
)

func main() {
    cwd, err := os.Getwd()
    if err != nil {
        log.Fatal(err)
    }

    srv := &http.Server{
        Addr:    ":8000", // Normally ":443"
        Handler: http.FileServer(http.Dir(cwd)),
    }
    log.Fatal(srv.ListenAndServe())
}

我们利用Go标准库的net/http包写了一个fileserver,我们build一下该server,并查看它是否有外部依赖以及未定义的符号:

$go build server.go
-rwxr-xr-x    1 tony  staff  5943828  6 27 14:47 server*

$otool -L server
server:
    /usr/lib/libSystem.B.dylib (compatibility version 0.0.0, current version 0.0.0)
    /System/Library/Frameworks/CoreFoundation.framework/Versions/A/CoreFoundation (compatibility version 0.0.0, current version 0.0.0)
    /System/Library/Frameworks/Security.framework/Versions/A/Security (compatibility version 0.0.0, current version 0.0.0)
    /usr/lib/libSystem.B.dylib (compatibility version 0.0.0, current version 0.0.0)
    /usr/lib/libSystem.B.dylib (compatibility version 0.0.0, current version 0.0.0)

$nm server |grep " U "
                 U _CFArrayGetCount
                 U _CFArrayGetValueAtIndex
                 U _CFDataAppendBytes
                 U _CFDataCreateMutable
                 U _CFDataGetBytePtr
                 U _CFDataGetLength
                 U _CFDictionaryGetValueIfPresent
                 U _CFEqual
                 U _CFNumberGetValue
                 U _CFRelease
                 U _CFStringCreateWithCString
                 U _SecCertificateCopyNormalizedIssuerContent
                 U _SecCertificateCopyNormalizedSubjectContent
                 U _SecKeychainItemExport
                 U _SecTrustCopyAnchorCertificates
                 U _SecTrustSettingsCopyCertificates
                 U _SecTrustSettingsCopyTrustSettings
                 U ___error
                 U ___stack_chk_fail
                 U ___stack_chk_guard
                 U ___stderrp
                 U _abort
                 U _fprintf
                 U _fputc
                 U _free
                 U _freeaddrinfo
                 U _fwrite
                 U _gai_strerror
                 U _getaddrinfo
                 U _getnameinfo
                 U _kCFAllocatorDefault
                 U _malloc
                 U _memcmp
                 U _nanosleep
                 U _pthread_attr_destroy
                 U _pthread_attr_getstacksize
                 U _pthread_attr_init
                 U _pthread_cond_broadcast
                 U _pthread_cond_wait
                 U _pthread_create
                 U _pthread_key_create
                 U _pthread_key_delete
                 U _pthread_mutex_lock
                 U _pthread_mutex_unlock
                 U _pthread_setspecific
                 U _pthread_sigmask
                 U _setenv
                 U _strerror
                 U _sysctlbyname
                 U _unsetenv

通过otool和nm的输出结果我们惊讶的看到:默认采用“静态链接”的Go程序怎么也要依赖外部的动态链接库,并且也包含了许多“未定义”的符号了呢?问题在于cgo。

三、cgo对可移植性的影响

默认情况下,Go的runtime环境变量CGO_ENABLED=1,即默认开始cgo,允许你在Go代码中调用C代码,Go的pre-compiled标准库的.a文件也是在这种情况下编译出来的。在$GOROOT/pkg/darwin_amd64中,我们遍历所有预编译好的标准库.a文件,并用nm输出每个.a的未定义符号,我们看到下面一些包是对外部有依赖的(动态链接):

=> crypto/x509.a
                 U _CFArrayGetCount
                 U _CFArrayGetValueAtIndex
                 U _CFDataAppendBytes
                 ... ...
                 U _SecCertificateCopyNormalizedIssuerContent
                 U _SecCertificateCopyNormalizedSubjectContent
                 ... ...
                 U ___stack_chk_fail
                 U ___stack_chk_guard
                 U __cgo_topofstack
                 U _kCFAllocatorDefault
                 U _memcmp
                 U _sysctlbyname

=> net.a
                 U ___error
                 U __cgo_topofstack
                 U _free
                 U _freeaddrinfo
                 U _gai_strerror
                 U _getaddrinfo
                 U _getnameinfo
                 U _malloc

=> os/user.a
                 U __cgo_topofstack
                 U _free
                 U _getgrgid_r
                 U _getgrnam_r
                 U _getgrouplist
                 U _getpwnam_r
                 U _getpwuid_r
                 U _malloc
                 U _realloc
                 U _sysconf

=> plugin.a
                 U __cgo_topofstack
                 U _dlerror
                 U _dlopen
                 U _dlsym
                 U _free
                 U _malloc
                 U _realpath$DARWIN_EXTSN

=> runtime/cgo.a
                 ... ...
                 U _abort
                 U _fprintf
                 U _fputc
                 U _free
                 U _fwrite
                 U _malloc
                 U _nanosleep
                 U _pthread_attr_destroy
                 U _pthread_attr_getstacksize
                 ... ...
                 U _setenv
                 U _strerror
                 U _unsetenv

=> runtime/race.a
                 U _OSSpinLockLock
                 U _OSSpinLockUnlock
                 U __NSGetArgv
                 U __NSGetEnviron
                 U __NSGetExecutablePath
                 U ___error
                 U ___fork
                 U ___mmap
                 U ___munmap
                 U ___stack_chk_fail
                 U ___stack_chk_guard
                 U __dyld_get_image_header
                .... ...

我们以os/user为例,在CGO_ENABLED=1,即cgo开启的情况下,os/user包中的lookupUserxxx系列函数采用了c版本的实现,我们看到在$GOROOT/src/os/user/lookup_unix.go中的build tag中包含了+build cgo。这样一来,在CGO_ENABLED=1,该文件将被编译,该文件中的c版本实现的lookupUser将被使用:

// +build darwin dragonfly freebsd !android,linux netbsd openbsd solaris
// +build cgo

package user
... ...
func lookupUser(username string) (*User, error) {
    var pwd C.struct_passwd
    var result *C.struct_passwd
    nameC := C.CString(username)
    defer C.free(unsafe.Pointer(nameC))
    ... ...
}

这样来看,凡是依赖上述包的Go代码最终编译的可执行文件都是要有外部依赖的。不过我们依然可以通过disable CGO_ENABLED来编译出纯静态的Go程序:

$CGO_ENABLED=0 go build -o server_cgo_disabled server.go

$otool -L server_cgo_disabled
server_cgo_disabled:
$nm server_cgo_disabled |grep " U "

如果你使用build的 “-x -v”选项,你将看到go compiler会重新编译依赖的包的静态版本,包括net、mime/multipart、crypto/tls等,并将编译后的.a(以包为单位)放入临时编译器工作目录($WORK)下,然后再静态连接这些版本。

四、internal linking和external linking

问题来了:在CGO_ENABLED=1这个默认值的情况下,是否可以实现纯静态连接呢?答案是可以。在$GOROOT/cmd/cgo/doc.go中,文档介绍了cmd/link的两种工作模式:internal linking和external linking。

1、internal linking

internal linking的大致意思是若用户代码中仅仅使用了net、os/user等几个标准库中的依赖cgo的包时,cmd/link默认使用internal linking,而无需启动外部external linker(如:gcc、clang等),不过由于cmd/link功能有限,仅仅是将.o和pre-compiled的标准库的.a写到最终二进制文件中。因此如果标准库中是在CGO_ENABLED=1情况下编译的,那么编译出来的最终二进制文件依旧是动态链接的,即便在go build时传入-ldflags ‘extldflags “-static”‘亦无用,因为根本没有使用external linker:

$go build -o server-fake-static-link  -ldflags '-extldflags "-static"' server.go
$otool -L server-fake-static-link
server-fake-static-link:
    /usr/lib/libSystem.B.dylib (compatibility version 0.0.0, current version 0.0.0)
    /System/Library/Frameworks/CoreFoundation.framework/Versions/A/CoreFoundation (compatibility version 0.0.0, current version 0.0.0)
    /System/Library/Frameworks/Security.framework/Versions/A/Security (compatibility version 0.0.0, current version 0.0.0)
    /usr/lib/libSystem.B.dylib (compatibility version 0.0.0, current version 0.0.0)
    /usr/lib/libSystem.B.dylib (compatibility version 0.0.0, current version 0.0.0)

2、external linking

而external linking机制则是cmd/link将所有生成的.o都打到一个.o文件中,再将其交给外部的链接器,比如gcc或clang去做最终链接处理。如果此时,我们在cmd/link的参数中传入-ldflags ‘extldflags “-static”‘,那么gcc/clang将会去做静态链接,将.o中undefined的符号都替换为真正的代码。我们可以通过-linkmode=external来强制cmd/link采用external linker,还是以server.go的编译为例:

$go build -o server-static-link  -ldflags '-linkmode "external" -extldflags "-static"' server.go
# command-line-arguments
/Users/tony/.bin/go18/pkg/tool/darwin_amd64/link: running clang failed: exit status 1
ld: library not found for -lcrt0.o
clang: error: linker command failed with exit code 1 (use -v to see invocation)

可以看到,cmd/link调用的clang尝试去静态连接libc的.a文件,但由于我的mac上仅仅有libc的dylib,而没有.a,因此静态连接失败。我找到一个ubuntu 16.04环境:重新执行上述构建命令:

# go build -o server-static-link  -ldflags '-linkmode "external" -extldflags "-static"' server.go
# ldd server-static-link
    not a dynamic executable
# nm server-static-link|grep " U "

该环境下libc.a和libpthread.a分别在下面两个位置:

/usr/lib/x86_64-linux-gnu/libc.a
/usr/lib/x86_64-linux-gnu/libpthread.a

就这样,我们在CGO_ENABLED=1的情况下,也编译构建出了一个纯静态链接的Go程序。

如果你的代码中使用了C代码,并依赖cgo在go中调用这些c代码,那么cmd/link将会自动选择external linking的机制:

//testcgo.go
package main

//#include <stdio.h>
// void foo(char *s) {
//    printf("%s\n", s);
// }
// void bar(void *p) {
//    int *q = (int*)p;
//    printf("%d\n", *q);
// }
import "C"
import (
    "fmt"
    "unsafe"
)

func main() {
    var s = "hello"
    C.foo(C.CString(s))

    var i int = 5
    C.bar(unsafe.Pointer(&i))

    var i32 int32 = 7
    var p *uint32 = (*uint32)(unsafe.Pointer(&i32))
    fmt.Println(*p)
}

编译testcgo.go:

# go build -o testcgo-static-link  -ldflags '-extldflags "-static"' testcgo.go
# ldd testcgo-static-link
    not a dynamic executable

vs.
# go build -o testcgo testcgo.go
# ldd ./testcgo
    linux-vdso.so.1 =>  (0x00007ffe7fb8d000)
    libpthread.so.0 => /lib/x86_64-linux-gnu/libpthread.so.0 (0x00007fc361000000)
    libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007fc360c36000)
    /lib64/ld-linux-x86-64.so.2 (0x000055bd26d4d000)

五、小结

本文探讨了Go的可移植性以及哪些因素对Go编译出的程序的移植性有影响:

  • 你的程序用了哪些标准库包?如果仅仅是非net、os/user等的普通包,那么你的程序默认将是纯静态的,不依赖任何c lib等外部动态链接库;
  • 如果使用了net这样的包含cgo代码的标准库包,那么CGO_ENABLED的值将影响你的程序编译后的属性:是静态的还是动态链接的;
  • CGO_ENABLED=0的情况下,Go采用纯静态编译;
  • 如果CGO_ENABLED=1,但依然要强制静态编译,需传递-linkmode=external给cmd/link。

微博:@tonybai_cn
微信公众号:iamtonybai
github.com: https://github.com/bigwhite

如发现本站页面被黑,比如:挂载广告、挖矿等恶意代码,请朋友们及时联系我。十分感谢! 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