实现 go 的 goroutine 本地存储又一种方式

go 本身没有对外提供 goroutine 本地存储,而现实中,又经常需要在上下文中传递一些数据。使用 context 也是一种方式,但是要求在所有需要的地方都要传递,还是非常麻烦,而且有侵入性。

偶然发现 go 已经提供了一个用于 profile 的 pprof label,可以在 goroutine 中携带一些数据。不过这个东西既然是用于 pprof 的,随意往里塞太多东西显然也不适合,还会对 pprof 产生干扰。所以,想办法只用其中一个 label,用一些黑科技把一个 map 放了进去,将影响降到最小。同时,pprof 包中已经有一些基于 context 访问处理 label 的逻辑,所以还要做一些兼容处理,避免被其覆盖。

写了个简单的库:https://github.com/xiezhenye/gls

尝试让 cgo 调用绕过线程池

go 语言中,可以通过 cgo 来调用 C 库。但是由于 goroutine 的机制,外部的 C 函数调用可能能够很快返回,也可能执行很长时间。为了 goroutine 调度不被阻塞,就一律对每个 cgo 调用都从线程池中取一个线程来执行,完成后再返回原 goroutine。这样一来,每个 cgo 调用都带来了巨大的额外开销。所以 go 的很多库在实现时,都没有通过包装 C 库,而是选择完全用 go 来实现。这就使得 go 少了大量现有的 C 库可以利用。

读过 go 的代码后发现,要让 cgo 调用不通过线程池调用并不算麻烦,所以就自己修改了一下 cgo 命令。如下面的代码中

package cgo

//
// int add(int a, int b) {
//   int ret = a + b;
//   return ret;
// }
//
import "C"

func CAdd(a, b int) int {
  return int(C.add(C.int(a), C.int(b)))
}

func AsmCAdd(a, b int) int {
  return int(c.add(C.int(a), C.int(b)))
}

C.add 是传统的 cgo 调用方式。c.add 则是修改后不经过线程池的方式。两者可以并存,程序员可以自己判断 C 函数的执行时间,来考虑使用哪种方式。

性能测试代码:

import "testing"

func BenchmarkNormal(b *testing.B) {
  for i:= 0; i < b.N; i++ {
    CAdd(i, i);
  }
}

func BenchmarkDirect(b *testing.B) {
  for i:= 0; i < b.N; i++ {
    AsmCAdd(i, i);
  }
}

测试结果:

testing: warning: no tests to run
PASS
BenchmarkNormal	5000000	      307 ns/op
BenchmarkDirect	50000000	       31.0 ns/op
ok  	cgo	3.437s

可以看出,直接调用的性能大约是传统调用方式的 10 倍。

虽然目前的实现不算很漂亮,但是这玩意给了 go 更强的能力。而且只修改了 cgo 工具,并没有影响 go runtime 和标准库,不破坏兼容性。

代码在此,基于 go 1.4 修改。

https://github.com/xiezhenye/go/tree/directly-cgo/src/cmd/cgo

但是进一步实验发现这样还是有问题。

首先无法通过正常的方式调用 export 出来的 go 函数。这是因为正常的 cgo 调用会先 entersyscall,完成后 exitsyscall。在回调 go 时,会先 exitsyscall,然后 reentersyscall。既然之前跳过了那一步,这里自然就会出错。这点要解决就得改 runtime 了。

然后是个更要命的问题。这种方式下运行的 C 函数是运行在 goroutine 的栈里的。而 goroutine 的栈是由 go runtime 管理的。初始很小,按需扩大。但是 runtime 不知道 C 函数的情况,无法为其扩栈。如果 C 函数使用的栈空间超过了 goroutine 剩余的栈空间可能破坏其他 goroutine 的栈。这一点就基本不可能解决了。因为即使在 cgo 调用前预先扩栈,也不知道究竟需要扩多少才够,也无法对其进行保护,避免越界。

再探 goroutine

之前写过一篇 go 语言并发机制 goroutine 初探,后来对 go 如何跟踪 syscall 调用返回产生了兴趣,研究后发现,之前的一些看法是错误的。

问题是这么发现的。

// par.go
package main

import (
"fmt";
"runtime"
"strconv"
"time"
)

func main() {
        runtime.GOMAXPROCS(2)
        ch := make(chan int)
        n := 1000
        for i := 0; i < n; i++ {
                task(strconv.Itoa(i), ch, 100)
        }
        fmt.Printf("begin\n")
        for i := 0; i < n; i++ {
                <-ch
        }
}

func task(name string, ch chan int, max int) {
        go func() {
                i:= 1
                for i <= max {
                        fmt.Printf("%s %d\n", name, i)
                        i++
                }
                ch <- 1
        }();
}

这么一个程序,运行 ./par | less ,然后查看 /proc/<pid>/tasks,或者用类似的 pstree -p <pid> 。原来指望只有很少的线程数。结果却是 1002 个。看起来似乎是实实在在地为每个 goroutine 启动了一个线程。然后又用 strace -f ./par 2>&1 | less 跟踪,也发现了大量的 clone 系统调用。也就是说,goroutine 并不是像我之前认为的,在 cgocall 或者 syscall 的时候进行自动切换,而是使用了线程。同时,这个线程数和 runtime.GOMAXPROCS 也没有直接关联。在这个情况下,虽然 runtime.GOMAXPROCS 设为了 2 ,但是最后照样用了 1000 多个线程。但是 strace -f ./par 直接运行,此时跟踪线程数,最多就只有几十个。看来和 less 也有关系。

在 golang 的邮件列表里提问了解到,goroutine 在遇到阻塞性的系统调用,比如 Read ,或者 cgo 调用,会启用一个线程来处理这些调用。想想也和结果对应上了。因为使用了 less ,在显示完一屏幕,并且管道缓冲也被填满后,fmt.Printf 底层对应的 Write 系统调用全部被阻塞。而由于每一个系统调用都需要一个线程来处理,于是就有了 1000 多个线程。由此看来,goroutine 也并不是之前想象中的那么神奇。同时邮件列表里也提到,对于 net 包,还是使用了异步 io 系统调用,因此在网络应用中并不会由于网络 io 速度慢造成阻塞而产生大量线程。看了 net 包的源代码下的 netfd(net/fd*.go) ,确实如此。

go 语言要避免大量线程产生的切换开销,用类似 coroutine 的方式,还是得结合异步 io 。但是目前只在网络 io 上实现了这点。对于其他的 io,比如文件系统,仍然会由于阻塞而产生线程。如果应用中需要使用文件 io,就得使用生产者消费者模式来减少线程数量,或者可以考虑利用 netfd 的代码来实现一个其他类型 io 的异步包装(当然功能上会有一些限制)。

go 语言 Makefile 指定依赖包位置

编译 go 程序可以使用自带的一些 Makefile 脚本来简化编写 Makefile 。官方的文档过于简略,没提到需要指定依赖包位置的方法。翻过那几个脚本代码后,发现原来有 LDIMPORTS 和 GCIMPORTS 可以指定。

比如:

include $(GOROOT)/src/Make.inc

LDIMPORTS=-L ./pkg/_obj
GCIMPORTS=-I ./pkg/_obj

TARG=tool
GOFILES=\
	tool.go\

include $(GOROOT)/src/Make.cmd

GCIMPORTS 指定编译阶段的参数,对 Make.cmd,Make.pkg 都有效。LDIMPORTS 指定链接阶段的参数,这个对 Make.pkg 就没用了。

另外,还可以用类似 CLEANFILES+= pkg/_obj ,在 make clean 的时候来清理更多的东西。

以及

all: pkg/_obj tool

pkg/_obj:
	cd pkg; make

这样的方法在依赖包未编译时,自动编译依赖包。

go 语言并发机制 goroutine 初探

go 语言的一个很大的优势就是可以方便地编写并发程序。go 语言内置了 goroutine 机制。这是一种类似 coroutaine(协程) 的东西。但是又不完全相同。

比如这个例子:

package main
import (
    "fmt";
    "strconv"
)
func main() {
    ch := make(chan int)
    task("A", ch)
    task("B", ch)
    fmt.Printf("begin\n")
    <-ch
    <-ch
}
func task(name string, ch chan int) {
    go func() {
        i:= 1
        for {
            fmt.Printf("%s %d\n", name, i)
            i++
        }
        ch <- 1
    }();
}

运行以后,发现会 A B 两个 goroutine 会交替执行,并像传统的协程需要手动 schedule 。看起来很神奇。

稍稍改一下代码,把

fmt.Printf("%s %d\n", name, i)

改成

print(name + " " + strconv.Itoa(i) + "\n")

再看看。神奇的效果消失了,只有 A 被运行。

那么 fmt.Printf 和 print 有什么差别,导致了这个结果呢?

大致翻了一下 go 的代码,看出 go 语言在对 c lib 的包装上用了个 cgo 的方式。而在通过 cgo 调用 c 库的时候,会在调用时自动 schedule 切换走,在调用结束的时候再返回。这两个结果的差异就在于,fmt.Printf 是通过 cgo 包装的,而 print 则是原生实现的。所以在调用 fmt.Printf 的时候,就自动实现了调度。

传统的 coroutaine 在访问网络、数据库等 io 操作时仍然会阻塞,失去并发能力,所以通常需要结合异步 io 来实现。而现有的库如果本身未提供异步功能,就很难办,往往需要重新实现。而且,即使有异步 io 功能,也需要额外的开发,才能在表现上和以往顺序程序相同的方式。

go 语言的这种实现方式很好的解决了这个问题,可以充分利用现有的大量 c 库来包装。

同时,也还是可以使用 runtime.Gosched() 来手动调度。在运算量大的场景下,也还是必要的。

在使用 print 的例子里,如果使用 runtime.GOMAXPROCS(2),又可以重新并行起来。这时,两个 goroutine 是在两个独立的线程中运行的。这又是 goroutine 和协程的一个不同点。不过启用多个线程并不见得能让程序更快。因为跨线程的上下文切换代价也是很大的。在上面那个简单的例子里,还会让程序变得更慢。降低上下文切换开销也是协程的一个优势所在。


之后又对 goroutine 的实现研究了一下,发现了之前的一些错误。详见:再探-goroutine