Golang 和方便的 goroutine
go say("world")
goroutine 由于其对比操作系统线程非常明显的特点:
-
上下文切换开销更少
-
堆栈空间占用通常更少
-
goroutine 之间的通信模型更方便。
-
goroutine 调度对写代码的人来说是透明的。
在 Golang 程序中大量存在,可以说,不用它就相当不用 Golang。
也因为这种便宜好用的 Go协程,使得大量使用 Golang 编写的应用大行其道,今天我们来讲讲当使用 Golang 的这个特性开发应用时与 Linux namespace 相关的一些副作用。
GPM 模型
Golang runtime 从开始到演化成如今的 G-P-M 模型定义中,一个特定 G 代表一个特定 goroutine,M 代表操作系统线程 OSthread,P 则代表 Runtime 中的逻辑处理器。
G 是 Go 中的基本调度单位,是 Go 语言层面实现并发的最小粒度。G 的生命周期由 Go runtime 跟踪。goroutines 切换只需保存三个寄存器:Program Counter, Stack Pointer and BP。G 的 goid 被设置成私有。
M 是具体执行 G 的工具人,是操作系统层面最小粒度的调度单位,切换 M 的上下文(OSthread) 带来的开销过大,所以实现了更小粒度的 G。把一个个任务分配成 G。G 和 M 实现了 M:N 的用户态线程模型。
P 是 Go runtime 里定义的概念上的逻辑处理器,持有一个本地局部队列保存着 待运行的 G 。P 的加入是在 G 与 M 加入了一层,P 保存着 G 的栈信息,G 可以跨 M 执行。
-
M 需要向 P 请求接下来需要执行的 G。 -
G 是跑在 M 上的。 -
没办法控制在什么时候特定 G 被谁调度。
Runtime 中的协程
runtime.LockOSThread
,让 G 和 M 绑在一起。还提供了runtime.UnlockOSThread
解除这种绑定。package main
import (
"fmt"
"net"
"runtime"
"github.com/vishvananda/netns"
)
func main() {
// Lock the OS Thread so we don't accidentally switch namespaces
runtime.LockOSThread()
defer runtime.UnlockOSThread()
// Save the current network namespace
origns, _ := netns.Get()
defer origns.Close()
// Create a new network namespace
newns, _ := netns.New()
defer newns.Close()
// setNs with New and Do somethings
_ = netns.Set(ns)
// Do something with the network namespace
ifaces, _ := net.Interfaces()
fmt.Printf("Interfaces: %vn", ifaces)
// Switch back to the original namespace
netns.Set(origns)
}
LockOSThread
的 G 会和 M 绑定,在绑定状态下,-
G 不会被调度走; -
M 在 G 结束后也不能够回到 M pool 中等待运行其它 G,要直接被回收; -
runtime 保证不会出于调度 G 的目的(但 exec 会继承 #23570 )从被锁定的线程 clone 出新线程(#20676)。
LockOSThread
就是为了让 goroutine 拥有可靠地修改线程上下文的能力,比如 setns / unshare / exec / setxid,但是任何对线程上下文的修改都会让它被污染。如果 G 在结束前调用 UnlockOSThread 解除了锁定状态,那么 Go runtime 会认为这个 M 从良了。但实际上能不能等同于最开始的 M 需要开发人员来保证,或者通过静态分析 (static code analysis)来提前检出这些问题。-
M pool 还有空闲的,那 Runtime 从 M pool 里面取一个 M 来执行 G’。 -
如果 M pool 没有空闲的,那么 Runtime 会让 一个 干净的/没有被 LockOSThread 碰过的 Thread 执行 clone。为了 不与 runtime 保证不会出于调度 G 的目的(但 exec 会继承 #23570 )从被锁定的线程 clone 出新线程(#20676)相抵触。
package main
import (
"fmt"
"runtime"
"syscall"
"github.com/vishvananda/netns"
)
func checkErrAndPanic(err error) {
if err != nil {
panic(err)
}
}
func goroutineWithNs() {
originNs, err := netns.Get()
checkErrAndPanic(err)
tid := syscall.Gettid()
fmt.Printf("originNS: %s, tid: %dn", originNs.UniqueId(), tid)
ns, err := netns.New()
checkErrAndPanic(err)
runtime.LockOSThread()
defer runtime.UnlockOSThread()
err = netns.Set(ns)
checkErrAndPanic(err)
targetNetNS, err := netns.Get()
checkErrAndPanic(err)
// After SetNs() with CLONE_NEWNET
fmt.Printf("-targeNS: %s, tid: %dn", targetNetNS.UniqueId(), syscall.Gettid())
wait := make(chan struct{})
// Spwan a new goroutine, with origin net namespace
go func() {
goroutineNetNS, err := netns.Get()
checkErrAndPanic(err)
// new goroutine dosen't work under the targetNetNS
fmt.Printf("goroutineNetNS: %s, tid: %d n", goroutineNetNS.UniqueId(), syscall.Gettid())
wait <- struct{}{}
}()
<-wait
}
func main() {
mainNs, err := netns.Get()
checkErrAndPanic(err)
fmt.Printf("mainNs: %s, tid: %dn", mainNs.UniqueId(), syscall.Gettid())
goroutineWithNs()
lastestNs, err := netns.Get()
checkErrAndPanic(err)
fmt.Printf("lastestNs: %s, tid: %dn", lastestNs.UniqueId(), syscall.Gettid())
if !lastestNs.Equal(mainNs) {
fmt.Printf("the original prog has be poisoned. n")
}
}
会得到:
ubuntu$ sudo strace -f -o log ./lockosthread
mainNs: NS(4:4026531992), tid: 2204650
originNS: NS(4:4026531992), tid: 2204650
-targeNS: NS(4:4026532235), tid: 2204650
goroutineNetNS: NS(4:4026531992), tid: 2204652
lastestNs: NS(4:4026532235), tid: 2204650
the original prog has be poisoned.
从保存下来的 strace log 上观察,setns 系统调用有且仅有调用过一次。
-
进程虽然还是那个进程,但是不知道不觉就因为调用的函数把自己的 netns 给换了。 -
goroutine / 协程 可能想进入某个 ns 并且想啪地一下地产生更多的 goroutine(goroutine 没有父子关系)结果发现并没有继承关系。
结 论
-
Go 选手请不要从锁定的 goroutine 生成新的 goroutine。
-
在 Go 1.10 之后,开发者应该如果在 LockOSThread 的协程上调用了复杂的第三方库函数,这个第三方库函数自己 Go func 得很开心,可能导致开发者自己也不清楚是不是踩了这种坑,我觉得应该 propose 一个新的 API 或者让 Golang 的编译器通过静态分析之类的技术手段来保证不会从当前 goroutine 上产生新的 goroutine,不然预期的这个第三方库函数并不会在预期 Lock 住的状态下运行。
-
如果执行的是 os/exec 之类的,那么就不是出于调度 G 的目的的 fork 线程,那么不受 Runtime 限制。Docker 早期为了避开操作命名空间的这类问题采用了 cgo 的方法。
若在语言层面上提供协程支持的编程语言们应该都对以上的情况对应的解决方案,如果没有,就应该在编码上约法三章。比如以下趣闻。
趣 闻
有 Go 1.10 修解决这几个问题之前,需要跨 namespace 操作东西的要么是 docker / runc / cni 那几个玩意。CNI 的开发者就提倡了注意几条规则来规避 Go Runtime 的问题。
https://github.com/containernetworking/cni/issues/262
For now, the only suggestion I can make is that CNI plugins should obey the following 3 rules:
· be short-lived (as you said)
· be single-threaded, single-goroutine
· never re-enter NetNS.Do()
Reference
https://github.com/vishvananda/netns
https://tanzu.vmware.com/content/pivotal-engineering-journal
https://www.bookstack.cn/read/qcrao-Go-Questions/goroutine 调度器-GPM 是什么.md
https://github.com/rosenhouse/ns-mess/tree/master/c-demos
https://www.weave.works/blog/linux-namespaces-golang-followup
https://github.com/weaveworks/weave/blob/v2.2.0/net/netns.go#L65
以及本文中直接引用的 Github issue 就不在这里重复了。
(边界无限靖云实验室Aklis供稿)
原文始发于微信公众号(实战攻防):基础研究 | Go语言:goroutine 的副作用