Java/C++ 程序员们在写 Go 程序时,也许会有如下的疑问,为什么 Go 没有 volatile 关键字。

本文会试图帮助我们理解如下两个问题:

  1. Java 中的 volatile 有什么作用
  2. Go 为什么没有 volatile,如何在 Go 中等效实现 Java 中 volatile 的作用

1. Java 中的 volatile

Java 中的 volatile 用于确保:

  • 可见性:当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。
  • 有序性:编译器优化、JIT 优化、CPU 优化都可能导致指令重排序,以提高执行效率。volatile 用于禁止对 volatile 字段操作的前后指令重排序。

在 Java 中 synchronized 关键字能够提供 volatile 提供的并发安全语义同时,提供更好的并发安全能力,但是 synchronized 太重了,没有 volatile 轻量,性能差。

1.1 volatile 的可见性

下面是一段代码,由于字段 flag 没有添加 volatile,flag 字段在子线程修改后的值对 main 线程不可见。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public class Visibility{
       public static void main(String[] args) {
        Tag tag = new Tag();

        new Thread(()->{
            try {
                // sleep is necessary
                Thread.sleep(1000);
                tag.flag = -1;
                System.out.println("flag was updated to -1");
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }).start();
        // main thread check flag in loop
        while(tag.flag !=-1){

        }
        System.out.println("main thread read flag is -1 successful");
    }
    static class Tag{
        // volatile int flag = 0;
        int flag =0;
    }
}

运行代码后,在控制台会打印出:

1
2
./run.sh
flag was updated to -1

但是 main 线程无法得到退出,这说明 main 线程始终在 while(tag.flag !=-1) 的循环检测中,这也意味着 main 线程并没能读取到 flag 的最新值。

如果我们给 flag 字段添加 volatile 修饰,再次运行代码,那么控制台会打印出:

1
2
3
./run.sh
main thread read flag is -1 successful
flag was updated to -1

并且 main 线程得到了退出。

这就证明了 volatile 修饰符在 Java 线程间起到的可见性作用。

1.2 volatile 的有序性

处理器为了提高程序运行效率,可能会对输入代码进行优化,它不保证程序中各个语句的执行先后顺序同代码中的顺序一致,但是它会保证程序最终执行结果和代码顺序执行的结果是一致的。

但是 CPU 指令重排序仅仅会考虑单线程执行模型,而不会考虑多线程执行模型。即不考虑并发安全性。

volatile 能够确保一定的有序性,例如:

1
2
3
4
5
6
7
//这里 x、y 为非 volatile 变量
//而 flag 为 volatile 变量
x = 2;         //语句1
y = 0;         //语句2
flag = true;   //语句3
x = 4;         //语句4
y = -1;        //语句5

由于 flag 是 volatile 变量,确保了:指令 1、2 一定在语句 3 之前执行,语句 4、5 一定在语句 3 后面执行。但是 volatile 并不会确保语句 1、2 之间不进行指令重排序,语句 4、5 之间不进行指令重排序。

理解指令重排序的例子是 doule check lock(DCL)单例模式,如下所示:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
public class Student {
      String name;
      int age;
      // private constructor
      private Student(String name, int age) {
          this.name = name;
          this.age = age;
      }
      // use volatile
      private static volatile Student student;
      //double check null
      public static Student getStudent() {
          if (student == null) {
              synchronized (Student.class) {
                  if (student == null) {
                      student = new Student("hello world",12);
                  }
              }
          }
          return student;
      }
      public String getName() {
          return name;
      }
      public int getAge() {
          return age;
      }
  }

单例模式通常会要求单例字段被 volatile 修饰,这是因为 new 一个实例实际上分为三步非原子操作来完成:

1
2
3
memory=allocate(); //1:分配内存空间
ctorInstance();   //2:初始化对象
singleton=memory; //3:设置singleton指向刚排序的内存空间

CPU 会对上述三个操作进行指令重排序:

  • 内存分配一定要第一步执行,否则会影响语义,因此 CPU 不会对其进行指令重排;
  • 初始化对象以及设置 singleton 指向刚申请的内存空间可能会被指令重排,因为在单线程模型下这两步即使倒过来做,也没有影响。

如果单例字段不使用 volatile 修饰,那么在并发环境下就会遇到这样的问题:线程 1 先给 student 赋值上没有初始化完成的 Student 类实例。线程 2 通过单例方法得到了该实例,在调用相关方法时却发现字段尚未初始化,最终抛出了异常。

使用 volatile 修饰单例字段就能够避免上述并发安全问题,为什么?

这是因为 new 操作对应的第三步实际上是给 volatile 字段赋值,它会要求第二步必须在其之前就已经运行完毕,这就能够避免因为指令重排序而导致的单例提前暴露。

2. Go 没有 volatile

2.1 并发安全问题示例

Go 没有 volatile 关键字,这有可能导致多线程并发安全问题,下面的代码是一个示例。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
package main

import (
	"fmt"
	"time"
)

var flag int = 0

func main() {
	go func() {
		time.Sleep(1 * time.Second)
		flag = -1
		fmt.Println("flag was updated to -1")
	}()
	for flag != -1 {
	}
	fmt.Println("main goroutine read flag is -1 successful")
}

运行命令:

1
go run -race main.go

其中 -race 用于开启多线程并发安全问题检测。输出如下内容:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
==================
WARNING: DATA RACE
Write at 0x0001005615e0 by goroutine 7:
  main.main.func1()
      /Users/wjjiang/goproject/volatile_javago/go/race/main.go:13 +0x44

Previous read at 0x0001005615e0 by main goroutine:
  main.main()
      /Users/wjjiang/goproject/volatile_javago/go/race/main.go:16 +0x48

Goroutine 7 (running) created at:
  main.main()
      /Users/wjjiang/goproject/volatile_javago/go/race/main.go:11 +0x38
==================
flag was updated to -1
main goroutine read flag is -1 successful
Found 1 data race(s)
exit status 66

上述提示意味着多线程间字段更新不可见导致的多线程并发安全问题。

如何解决 Go 中没有 volatile 的问题?

有 3 个方案:

  1. 使用锁来实现同步,这类似于 Java 中的 synchronized 关键字
  2. 使用 channel,Go 的基本设计思想是 "Do not communicate by sharing memory; instead, share memory by communicating."
  3. 使用原子操作解决 volatile 的可见性

2.2 利用 mutex 解决 Go 没有 volatile

与 Java 相同,Go 也能利用锁来实现多线程间的同步,解决线程间的可见性问题。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
package main

import (
	"fmt"
	"sync"
	"time"
)

var flag int = 0
var lock sync.Mutex

func main() {
	go func() {
		time.Sleep(1 * time.Second)
		lock.Lock()
		defer lock.Unlock()
		flag = -1
		fmt.Println("flag was updated to -1")
	}()
	for {
		lock.Lock()
		if flag == -1 {
			break
		}
		lock.Unlock()
	}
	fmt.Println("main goroutine read flag is -1 successful")
}

运行如下命令:

1
go run -race main.go

控制台输出:

1
2
flag was updated to -1
main goroutine read flag is -1 successful

可见,我们利用 sync.Mutex 提供的互斥锁解决了 Go 没有 volatile 导致的字段不可见问题。

2.3 利用 channel 解决 Go 没有 volatile

Go 的基本设计思想是 "Do not communicate by sharing memory; instead, share memory by communicating."

换言之,Go 并不推荐我们在内存中使用共享变量 flag 的形式来实现通信,而是利用 channel 来实现通信。在这个具体问题上,之前:

  • child goroutine 负责写 flag 字段
  • main goroutine 负责通过读取 flag 字段被修改为 -1 后,继续执行后续的代码逻辑

使用 channel:

  • child goroutine 负责向 channel 写一个 int 消息
  • main goroutine 负责从 channel 中读取一个 int 消息后,继续执行后续的代码逻辑

代码如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
package main

import (
	"fmt"
	"time"
)

// var flag int = 0
var c chan int = make(chan int)

func main() {
	go func() {
		time.Sleep(1 * time.Second)
		c <- -1
		fmt.Println("child goroutine send to channel a -1")
	}()
	<-c
	fmt.Println("main goroutine read from channel with a -1")
}

运行命令:

1
go run -race main.go

控制台输出:

1
2
main goroutine read from channel with a -1
child goroutine send to channel a -1

channel 也解决了 Go 中没有 volatile 的问题。

注意事项:channel 只是 Go runtime 提供的编写多线程并发代码的设计模式,其内部实现还是基于锁。换言之,Go runtime 内部还是以共享内存+锁的形式实现并发安全,但是 Go 代码使用 channel 避免了手写共享内存的代码。因此从性能上看,使用 channel 和使用 sync.Mutex 不会有太大的区别。

2.4 利用 atomic 解决 Go 没有 volatile

使用 sync/atomic 提供的原子读写能力,也能够解决 Go 没有 volatile 的问题,代码如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
package main

import (
	"fmt"
	"sync/atomic"
	"time"
)

var flag int64 = 0

func main() {

	go func() {
		time.Sleep(1 * time.Second)
		// flag = -1
		atomic.StoreInt64(&flag, -1)
		fmt.Println("flag was updated to -1")
	}()
	// for flag != -1
	for atomic.LoadInt64(&flag) != -1 {

	}
	fmt.Println("main goroutine read flag is -1 successful")
}

运行命令:

1
go run -race main.go

控制台输出:

1
2
flag was updated to -1
main goroutine read flag is -1 successful

可见,利用原子操作能够解决 Go 没有 volatile 的问题。从性能角度上看,原子操作的性能要快于基于 sync.Mutex 以及 channel 这两个解决办法。但是从编程难度上看,锁以及 channel 比使用 sync/atomic 更简单、更不易出错。

3. 总结

Java 中的 volatile 关键字用于提供可见性以及有序性两个语义。

Go 中没有 Java 的 volatile 关键字,这是因为 volatile 服务于共享内存,而 Go 的设计者推崇 Do not communicate by sharing memory; instead, share memory by communicating。换言之,如果没有共享内存,只有通信,那么就不需要 volatile 关键字了。

Go 利用如下方案能够实现 volatile 提供的可见性能力:

  • sync.Mutex 结构体
  • channel 机制
  • sync/atomic 包

Go 比 Java 以及 C++ 更追求优雅&简单地编写多线程并发程序,利用 go 关键字简单快速地启动一个协程就是最大的特点。因此,在大多数应用常见下,推荐使用 channel 机制来避免多线程之间共享内存。

本文所有涉及的代码都在如下开源项目进行了开源:https://github.com/spongecaptain/volatile_go

其中:

REFERECE