volatileとatomicの違い

volatileとatomicの違いを調べるために、以下のC++プログラムをコンパイルしてみる。

#include <atomic>

void func1(int *p) {
  ++*p; ++*p;
}

void func2(volatile int *p) {
  ++*p; ++*p;
}

void func3(std::atomic_int *p) {
  ++*p; ++*p;
}
$ g++ -std=c++11 -pthread -O2 -Wall -Wextra -g -c func.cpp -o func.o

環境による可能性はあるが、出力された機械語は端的に言うと次のようなものになる。(なおアーキテクチャLinux x86-64)

func1:
	addl	$2, (%rdi)
	ret
func2:
	movl	(%rdi), %eax
	addl	$1, %eax
	movl	%eax, (%rdi)
	movl	(%rdi), %eax
	addl	$1, %eax
	movl	%eax, (%rdi)
	ret
func3:
	lock addl	$1, (%rdi)
	lock addl	$1, (%rdi)
	ret
  • func1 は、「2を足す」という動作をしている。
  • func2 は、「メモリから読み込んで1を足して書き込む」という動作を2回している。
  • func3 は、「lockしながら1を足す」という動作を2回している。

これは以下の違いによる:

  • volatile は、メモリの読み込みや書き込みを、副作用を伴う動作と見なす。*1 そのため、読み込みや書き込み動作を減らす最適化を行わない。ただし、回数や順番さえ合っていればよいので、他のスレッドに干渉されるかどうかは考えない。
  • atomic は、他のスレッドが同時に読み書きしようとしても、あるひとまとまりの動作の間は独占的に動作するような振舞いになる。x86の場合はlockプレフィックスで実現できる。

動作確認

この動作は以下のプログラムで確認できる。

#include <cstdio>
#include <atomic>
#include <thread>

void func1(int *p);
void func2(volatile int *p);
void func3(std::atomic_int *p);

void count1(int *p) {
  for(int i = 0; i < 1000000; ++i) {
    func1(p);
  }
}

void count2(volatile int *p) {
  for(int i = 0; i < 1000000; ++i) {
    func2(p);
  }
}

void count3(std::atomic_int *p) {
  for(int i = 0; i < 1000000; ++i) {
    func3(p);
  }
}

int main(int argc, char *argv[]) {
  int num = -1;
  if(argc > 1) {
    std::sscanf(argv[1], "%d", &num);
  }
  if(num == 1) {
    int x = 0;
    std::thread th0(&count1, &x);
    std::thread th1(&count1, &x);
    th0.join();
    th1.join();
    std::printf("%d\n", x);
  } else if(num == 2) {
    volatile int x = 0;
    std::thread th0(&count2, &x);
    std::thread th1(&count2, &x);
    th0.join();
    th1.join();
    std::printf("%d\n", x);
  } else if(num == 3) {
    std::atomic_int x = ATOMIC_VAR_INIT(0);
    std::thread th0(&count3, &x);
    std::thread th1(&count3, &x);
    th0.join();
    th1.join();
    std::printf("%d\n", (int)x);
  }
  return 0;
}
$ g++ -std=c++11 -pthread -O2 -Wall -Wextra -g -c main.cpp -o main.o
$ g++ -std=c++11 -pthread -O2 -Wall -Wextra -g func.o main.o -o main
$ ./main 1
2497250
$ ./main 1
2432386
$ ./main 1
3136510
$ ./main 1
2411326
$ ./main 1
3466956
$ ./main 1
2367656
$ ./main 1
2297168
$ ./main 2
2324260
$ ./main 2
3137164
$ ./main 2
2374254
$ ./main 2
2627152
$ ./main 2
2593840
$ ./main 2
2871581
$ ./main 2
2218822
$ ./main 2
2617198
$ ./main 3
4000000
$ ./main 3
4000000
$ ./main 3
4000000
$ ./main 3
4000000
$ ./main 3
4000000
$ ./main 3
4000000
$ ./main 3
4000000

これはマルチコアの動作結果に関わるので環境によって異なる動作をするかもしれない。手元の環境はVirtualBoxで仮想化されたLinux x86-64であった。

atomicでは不十分な場合もある

atomicで一まとまりの動作と見なされる範囲はごく小さい。例えば*pが偶数であったとすると、以下の関数を繰り返し実行しても*pの値が変わらないことが意図される(シングルスレッドではそうなる)が、マルチスレッドではそうはならない。

#include <atomic>

void func(std:atomic_int *p) {
  *p += 1;
  *p ^= 1;
}

このような場合は単に*pをatomicにするだけでは不十分だが、mutexなどを使えばうまくいく。

*1:これはマルチスレッドのための仕組みというよりも、Memory-Mapped I/Oなどでメモリの読み書きが動作を伴う場合が想定されていると思われる。