Golang锁失效因之value receiver

服务器

浏览数:35

2020-6-22

AD:资源代下载服务

先说结论

golang中,值类型在作为方法参数和方法的接受者的时候,都需要进行值的拷贝,所以,使用值类型的时候要多加注意。 对于方法的接受者,如果方法需要修改接受者的某个变量值,那么就应该把接受者设计成pointer receiver,否则对于receiver变量的修改将无效。

问题由来

今天群里有人发了下面的代码:

type data struct {
	sync.Mutex
}

func (d data) test(s string) {
	d.Lock()
	defer func() {
		d.Unlock()
		println("success")
	}()

	for i := 0; i < 5; i++ {
		fmt.Println(s, i)
		time.Sleep(time.Second)
	}
}

func main() {
	var wg sync.WaitGroup
	wg.Add(2)

	var d data

	go func() {
		defer wg.Done()
		d.test("read")
	}()

	go func() {
		defer wg.Done()
		d.test("write")
	}()

	wg.Wait()
}

这段代码的运行结果如下:

write 0
read 0
read 1
write 1
read 2
write 2
read 3
write 3
write 4
read 4
success
success

对此他的疑问是:为什么会是这样呢?read和write为什么是交替执行?程序里面加了锁,锁为什么没生效呢? 因为如果锁生效,结果应该是先输出所有的write或者read,然后再输出另外一个。

问题原因

先看代码,上面代码中方法test的接受者为data,注意,这个data值类型(value receiver)。 在golang中,对于值类型在进行参数传递的时候传递的是值的拷贝。 在上面main方法中,两个go关键字开启了两个routine,这里面的两个d实际上不是同一个。所以在执行的过程中,锁也不是同一个,所以就不会出现锁失效的情况。

原因验证

我们可以通过输出一下对象的地址来看一下。 把test方法改一下:

func (d data) test(s string) {
	d.Lock()
	defer func() {
		d.Unlock()
		println("success")
	}()

	for i := 0; i < 5; i++ {
		fmt.Printf("%s, %d, object addr: %p \n", s, i, &d)
		time.Sleep(time.Second)
	}
}

我们通过fmt.Printf("%p")来输出一下d的地址。其他代码不变。运行结果如下:

write, 0, object addr: 0xc82000ae78 
read, 0, object addr: 0xc8200ce000 
read, 1, object addr: 0xc8200ce000 
write, 1, object addr: 0xc82000ae78 
read, 2, object addr: 0xc8200ce000 
write, 2, object addr: 0xc82000ae78 
write, 3, object addr: 0xc82000ae78 
read, 3, object addr: 0xc8200ce000 
read, 4, object addr: 0xc8200ce000 
write, 4, object addr: 0xc82000ae78 
success
success

可见,输出的d的地址并不相同。也就是说,实际执行的时候,是有两个data对象的,所以锁也不同,达不到公用锁的目的,所以输出结果就是乱序的。

是不是与匿名组合有关?

data的定义:

type data struct {
	sync.Mutex
}

结构体中的sync.Mutex没有命名。这种方式被称为匿名组合。匿名组合可以使一个结构体具有被匿名组合的结构的方法。类似于java中的继承。 那么是不是匿名组合导致的呢?答案是否定的,其实前面已经提到了,根源在于test方法的接受者是一个值类型而不是引用类型,从而导致两个goroutine中test方法的执行实际上是基于两个不同的data对象,所以它们的锁也不同。无论是否匿名组合都会有这个问题。下面的代码去掉了匿名组合:

type data struct {
	lock sync.Mutex
}

func (d data) test(s string) {
	d.lock.Lock()
	defer func() {
		d.lock.Unlock()
		println("success")
	}()

	for i := 0; i < 5; i++ {
		fmt.Printf("%s, %d, object addr: %p, lock address: %p \n", s, i, &d, &(d.lock))
		time.Sleep(time.Second)
	}
}

运行结果如下:

write, 0, object addr: 0xc82000ae78, lock address: 0xc82000ae78 
read, 0, object addr: 0xc8200ce000, lock address: 0xc8200ce000 
read, 1, object addr: 0xc8200ce000, lock address: 0xc8200ce000 
write, 1, object addr: 0xc82000ae78, lock address: 0xc82000ae78 
read, 2, object addr: 0xc8200ce000, lock address: 0xc8200ce000 
write, 2, object addr: 0xc82000ae78, lock address: 0xc82000ae78 
write, 3, object addr: 0xc82000ae78, lock address: 0xc82000ae78 
read, 3, object addr: 0xc8200ce000, lock address: 0xc8200ce000 
read, 4, object addr: 0xc8200ce000, lock address: 0xc8200ce000 
write, 4, object addr: 0xc82000ae78, lock address: 0xc82000ae78 
success
success

另一个解决办法

对于这个问题,除了把test方法的receiver改成pointer receiver之外,还有没有其他办法呢?答案是肯定的,只需要把匿名组合中的锁改成data结构体中显示变量,然后把锁由值类型改成引用类型即可。代码如下:

type data struct {
	lock *sync.Mutex
}

main方法中把var d data改一下:

    //var d data
	var d data = data{lock:&sync.Mutex{}}

完整代码如下:

type data struct {
	lock *sync.Mutex
}

func (d data) test(s string) {
	d.lock.Lock()
	defer func() {
		d.lock.Unlock()
		println("success")
	}()

	for i := 0; i < 5; i++ {
		fmt.Printf("%s, %d, object addr: %p, lock address: %p \n", s, i, &d, (d.lock))
		time.Sleep(time.Second)
	}
}

func main() {
	var wg sync.WaitGroup
	wg.Add(2)

	//var d data
	var d data = data{lock:&sync.Mutex{}}

	go func() {
		defer wg.Done()
		d.test("read")
	}()
	go func() {
		defer wg.Done()
		d.test("write")
	}()

	wg.Wait()
}

运行结果:

write, 0, object addr: 0xc82002e028, lock address: 0xc82000ae78 
write, 1, object addr: 0xc82002e028, lock address: 0xc82000ae78 
write, 2, object addr: 0xc82002e028, lock address: 0xc82000ae78 
write, 3, object addr: 0xc82002e028, lock address: 0xc82000ae78 
write, 4, object addr: 0xc82002e028, lock address: 0xc82000ae78 
success
read, 0, object addr: 0xc8200d8000, lock address: 0xc82000ae78 
read, 1, object addr: 0xc8200d8000, lock address: 0xc82000ae78 
read, 2, object addr: 0xc8200d8000, lock address: 0xc82000ae78 
read, 3, object addr: 0xc8200d8000, lock address: 0xc82000ae78 
read, 4, object addr: 0xc8200d8000, lock address: 0xc82000ae78 
success

由上面运行结果可以看出,data对象d在实际运行过程中依然是两个对象,但是它们的锁却是相同的,这样也能达到同步的目的。 为什么呢? 因为虽然值类型在方法传递的时候会进行一次拷贝,但是对于指针类型的字段来说,拷贝的是指针的地址,所以两个地址实际上是一样的,都指向同一把锁。

data的值是何时拷贝的呢?

golang中,方法传递是值传递,在作为方法参数的时候,如果是值类型,将会对数据进行一次拷贝,这样在方法中对于参数的修改不会影响原来的值。 但是在第一个版本代码的main方法中,并没有对data类型变量d进行参数传递,那么d是如何被拷贝的呢?我们输出一下d的地址:

type data struct {
	sync.Mutex
}

func (d data) test(s string) {
	d.Lock()
	defer func() {
		d.Unlock()
		println("success")
	}()

	for i := 0; i < 5; i++ {
		fmt.Printf("%s, %d, object addr: %p \n", s, i, &d)
		time.Sleep(time.Second)
	}
}

func main() {
	var wg sync.WaitGroup
	wg.Add(2)

	var d data

	fmt.Printf("initial address of d: %p \n", &d)

	go func() {
		defer wg.Done()
		fmt.Printf("1 address of d: %p \n", &d)
		d.test("read")
	}()
	go func() {
		defer wg.Done()
		fmt.Printf("2 address of d: %p \n", &d)
		d.test("write")
	}()

	wg.Wait()
}

结果如下:

initial address of d: 0xc820072da8 
2 address of d: 0xc820072da8 
write, 0, object addr: 0xc820072e60 
1 address of d: 0xc820072da8 
read, 0, object addr: 0xc82000a0e0 
write, 1, object addr: 0xc820072e60 
read, 1, object addr: 0xc82000a0e0 
write, 2, object addr: 0xc820072e60 
read, 2, object addr: 0xc82000a0e0 
write, 3, object addr: 0xc820072e60 
read, 3, object addr: 0xc82000a0e0 
read, 4, object addr: 0xc82000a0e0 
write, 4, object addr: 0xc820072e60 
success
success

通过结果我们发现,在main方法中,两个go关键字开始的goroutine中,输出的d的地址都是相同的,都为“0xc820072da8”,而真正执行test方法之后,输出的地址却成了“0xc82000a0e0”和“0xc820072e60”。显然,d是进行了拷贝的。我们保持其他代码不变,把goroutine去掉,用单线程试一下:

func main() {
	var d data
	fmt.Printf("initial address of d: %p \n", &d)

	d.test("read")
	d.test("write")
}

输出结果:

initial address of d: 0xc82000ae78 
read, 0, object addr: 0xc82000af20 
read, 1, object addr: 0xc82000af20 
read, 2, object addr: 0xc82000af20 
read, 3, object addr: 0xc82000af20 
read, 4, object addr: 0xc82000af20 
success
write, 0, object addr: 0xc8200cc038 
write, 1, object addr: 0xc8200cc038 
write, 2, object addr: 0xc8200cc038 
write, 3, object addr: 0xc8200cc038 
write, 4, object addr: 0xc8200cc038 
success

可以看到,同样的,一共出现了三个不同的地址,也就是说,test方法调用了两次,每次都是在不同的data对象上执行的。

那么我们可以得出结论:test方法在执行的时候,它的receiver如果是值类型,那么每次方法执行也需要进行一次拷贝。 总结一下:对于值类型来说,无论是作为参数传递给其他方法还是作为方法的receiver,都要进行值的拷贝。(因为值拷贝的目的就是为了避免对于值的修改会影响原来的值,所以对于方法的接受者或者方法参数来讲,处理逻辑都是一样的。)

总结:

golang中,值类型在作为方法参数和方法的接受者的时候,都需要进行值的拷贝,所以,使用值类型的时候要多加注意。 对于方法的接受者,如果方法需要修改接受者的某个变量值,那么就应该把接受者设计成pointer receiver,否则对于receiver变量的修改将无效。 比如:

type Person struct {
	name string
}

func (p Person) change(newName string) {
	p.name = newName
}

func main() {
	var p Person = Person{"jason"}
	p.change("john")

	fmt.Println(p.name)
}

输出结果为jason

另:关于value receiver 和 pointer receiver可以参考golang官方的Effective Go中的说明:https://golang.org/doc/effective_go.html#pointers_vs_values

作者:一条大河波浪宽