在MIT的6.824的第二节课中,一段展示并发golang爬虫代码很有意思,查阅了一些关于闭包的资料,结合自己的调试结果,记录一下。
type fetchState struct {
mu sync.Mutex
fetched map[string]bool
}
func ConcurrentMutex(url string, fetcher Fetcher, f *fetchState) {
f.mu.Lock()
already := f.fetched[url]
f.fetched[url] = true
f.mu.Unlock()
if already {
return
}
urls, err := fetcher.Fetch(url)
if err != nil {
return
}
var done sync.WaitGroup
for _, u := range urls {
done.Add(1)
//u2 := u
//go func() {
// defer done.Done()
// ConcurrentMutex(u2, fetcher, f)
//}()
go func(u string) {
defer done.Done()
ConcurrentMutex(u, fetcher, f)
}(u)
}
done.Wait()
return
}
这段代码是课堂展示的 a tour of go的最后一个exercise的一种解法(完整代码,虽然和上面的代码段有点出入,不过上面的代码段才是课堂上展示出来的)。课上有同学提出疑问说,能不能把go func(u string)改为 go func(),直接让变量u和闭包绑定在一起,从而省去传递u的麻烦。
当然答案是不行的,不过要完全回答这个问题,需要从闭包的实现机制和如何绑定外部变量的机制来看。
从下面这段代码来分析
package main
import (
"fmt"
)
func closure(i int) func() int {
fmt.Printf("outside i: %p\n", &i)
return func() int {
fmt.Printf("inner i: %p\n", &i)
i += 1
return i
}
}
func main() {
a := closure(5)
b := closure(7)
fmt.Println(a())
fmt.Println(b())
fmt.Printf("a:%p\n", a)
fmt.Printf("b:%p\n", b)
}
首先第一个问题是,我们调用返回的闭包函数时,代码是从在哪执行的?
或者说,闭包函数的二进制执行码位于最终可执行文件的什么位置?这个问题比较简单,一定得在代码段中才行。上面代码的执行结果如下:
outside i: 0xc00001a0d8
outside i: 0xc00001a100
inner i: 0xc00001a0d8
6
inner i: 0xc00001a100
8
a:0x49af20
b:0x49af20
通过readelf查看可执行文件可知,0x49ace0是位于代码段的一个地址,这解决了第一个问题。
第二个问题是,既然a, b两个函数指向同一个代码段的闭包函数,那应该执行相同了的代码,为什么能够作用于不同的变量i ?
这个需要反汇编可执行问题调试。下面是反汇编出来的一部分X86_64的汇编代码。这里调用的main.closure对应main函数中的 a := closure(5)
//......................
lea 0x100(%rsp),%rbp
movq $0x5,(%rsp)
call 0x49aa80 <main.closure>
//...................
在执行完call指令后,马上查看$rax的值,确认closure函数的返回值
(gdb) printf "%lx\n", $rax
c000010200 # 一个指向堆上地址的指针,并不是我们预想的0x49af20
(gdb) x/2xg $rax
0x000000000049af20 0x000000c00012e010
(gdb) x/1xg 0x000000c00012e010
0x0000000000000005
上面这一串结果就有意思了,可以看到,实际上closure函数在汇编层面返回的一个指向堆地址的指针,对应的堆上有16字节的数据,前8字节的数据对应了闭包函数的地址,后8字节的数据又是一个指向堆内存的指针,对应绑定了的i变量。
这样马上就能够理解闭包函数是怎么和变量绑定到一块的了。闭包绑定的变量是分配在堆内存上。本身所有的闭包函数的描述信息也通过一块堆内存保存,包括闭包函数的地址,以及绑定的所有变量的地址。只需要持有指向闭包函数描述信息的堆内存的指针就可以调用闭包函数了。
在实际调用闭包函数时( 例如上面的a() ),编译器在编译时,只需要隐式地把指向闭包函数描述信息的指针传递给闭包函数(反汇编代码中%rdx传递了地址0xc000010200),因为a, b两个闭包对应的描述信息中绑定的变量地址不同,所以同样的二进制执行码修改了不同的变量i。
现在回到之前那段爬虫代码,现在就可以明白为什么不可以把变量u和闭包函数进行绑定了。
设想绑定了之后,此时变量u一定会被分配到堆内存上,占16个字节,包括指向字符串的指针和字符串的长度。
此时对应的闭包函数描述信息则包括:闭包函数的地址,指向变量u的指针。那么这里就存在一个竞争关系了:一方面闭包函数需要通过这个指向u的指针获取u的信息,另一方面主线程通过循环不断修改u的值(即堆上的16字节的字符串描述信息)。
通过声明u为函数参数,那么在闭包函数中使用的u实际上是外部u的一个16字节拷贝,因此不会发生竞争关系。
其实最简单直观的一种从C++来看的理解方式是,闭包函数绑定的数据是外部数据的引用。