blog

アトミック操作と非アトミック操作

ウェブ上ではアトミック操作の導入に関する多くのコンテンツがあり、通常はアトミックな読み取り-変更-書き込み操作に焦点が当てられています。しかし、アトミック操作にはこれがすべてではありません。同様に重要...

Feb 7, 2015 · 9 min. read
シェア

ウェブ上ではアトミック操作について多くのことが書かれていますが、通常はアトミックな読み取り・変更・書き込み操作に焦点が当てられています。しかし、これらがアトミック操作のすべてではなく、同様に重要なアトミック・ロードとアトミック・ストアもあります。この記事では、アトミック・ロードとアトミック・ストアを、プロセッサ・レベルとC/C++言語レベルの両方で、対応する非アトミック演算と比較します。また、C++11における「データ競合」の概念についても説明します。

共有メモリでのアトミック操作とは、スレッド関連のシングルステップ操作を完了するかどうかです。アトミックストアが共有変数に作用すると、他のスレッドはこの未完成の変更値を監視できません。アトミックロードが共有変数に作用するとき、アトミックでないロードやストアがこのような保証をしないのに対して、アトミックロードはあたかも一瞬の出来事であるかのように完全な値を読み取ります。

これらの保証がなければ、異なるスレッドが同時に共有変数を操作することができないため、ロックフリーのプログラミングは不可能です。次のようなルールがあります:

2つのスレッドが同時に共有変数を操作しており、そのうちの1つが書き込み操作である場合、両方のスレッドはアトミック操作を使用しなければなりません。

このルールに違反し、すべてのスレッドが非アトミック演算を使用すると、C++11規格で言及されているデータ競合が発生します。データ競合がなぜ悪いのかは説明されていませんが、データ競合が発生すると必ず「未定義の動作」が発生します。このようなデータ競合が発生する理由は非常に単純です。

メモリ操作が非アトミックになるのは、非アトミックなマルチCPU命令を使用するからであり、シングルCPU命令を使用する場合でも、あなたが書いたポータブルコードを単純に想定できないからです。いくつかの例を見てみましょう。

非原子はマルチCPU命令によるもの

ゼロに初期化された64ビットのグローバル変数があるとします。

uint64_t sharedValue = 0; 

ある時点で、この変数に64ビット値を代入します。

void storeValue() 
{ 
     sharedValue = 0x100000002; 
} 

この関数を32ビットx86環境でGCCを使ってコンパイルすると、次のようなマシン・コードが生成されます。

$ gcc -O2 -S -masm=intel test.c 
$ cat test.s 
      ... 
      mov DWORD PTR sharedValue, 2 
      mov DWORD PTR sharedValue+4, 1 
      ret 
      ... 

今回、コンパイラはこの64ビット割り当てを行うために2つの別々のマシン命令を使用していることがわかります。最初の命令は下位32ビットを0×00000002に設定し、2番目の命令は上位32ビットを0×00000001に設定します。この代入操作が非アトミックであることは一目瞭然です。共有変数に異なるスレッドが同時にアクセスすると、多くのエラーが発生します:

  • スレッドが2つのマシン命令の間の隙間で最初にストアド変数を呼び出すと、0×00000000000002のような値をメモリに残します。この時点で、他のスレッドが共有変数を読むと、誰も保存したくなかった完全に改ざんされた値を受け取ることになります。
  • さらに悪いことに、あるスレッドが2つのマシン命令の間の隙間で先に変数を占有し、最初のスレッドが変数を再取得したときに別のスレッドがsharedValueを変更すると、永久に書き込みが中断されます。
  • マルチコア・デバイスでは、スレッドの1つが最初に所有しただけでは書き込み違反になりません。あるスレッドがstoreValueを呼び出すと、他のコアのどのスレッドでも、見かけ上変更されていないsharedValueを同時に読み取ることができます。

シェアードバリューを同時に読むと、一連の問題が生じます:

uint64_t loadValue() 
{ 
      return sharedValue; 
} 
  
$ gcc -O2 -S -masm=intel test.c 
$ cat test.s 
      ... 
      mov eax, DWORD PTR sharedValue 
      mov edx, DWORD PTR sharedValue+4 
      ret 
      ... 

この場合、sharedValueの同時ストアは、たとえ同時ストアがアトミックであったとしても、リード・ティアになります。ストアはアトミックです。

Mintomicテスト・セットには、test_load_store_64_failと呼ばれるテスト・ケースが含まれています。このテスト・ケースでは、スレッドが一般的な代入演算を使用して1つの変数に複数の64ビット値を格納し、別のスレッドが変数に対して単純なロードを繰り返し実行して各結果を確認します。を繰り返し実行します。マルチコアx86マシンでは、このテストは予想通り失敗しました。

#p#

非原子CPU命令

メモリ演算は、1つのCPU命令で実行される場合でも、非原子演算になることがあります。例えば、ARMv7命令セットアップには、32ビットソースレジスタの内容から2つの64ビット値をメモリに格納するstrd命令があります。

strd r0, r1, [r2] 

一部のARMv7プロセッサでは、この命令は非アトミックです。このプロセッサがこの命令に遭遇すると、実際には32ビットストアを2つ実行することになります。繰り返しますが、別のコアで実行されている別のスレッドが、ライト・ティアリングを観測する可能性があります。興味深いことに、ライト・ティアリングはシングルコア・デバイスで発生する可能性が高くなります。例えば、スケジュールされたスレッドのコンテキスト・スイッチのシステム割り込みは、実際に2つの内部32ビット・メモリ間で実行される可能性があります!この場合、このスレッドがこの割り込みから回復すると、再びこの strd 命令を呼び出します。

// Force sharedInt to cross a cache line boundary: 
#pragma pack(2) 
MINT_DECL_ALIGNED(static struct, 64) 
{ 
      char padding[62]; 
      mint_atomic32_t sharedInt; 
} 
g_wrapper; 

プロセッサ固有の詳細がたくさん出てきたので、C/C++言語レベルでのアトミック性をもう一度見てみましょう。

すべての C/C++ 操作は非原子として認識されます。

CおよびC++では、他のコンパイラやハードウェアベンダによって指定されない限り、通常の32ビット整数の代入であっても、すべての操作は非原子として認識されます。

uint32_t foo = 0; 
  
void storeFoo() 
{ 
      foo = 0x80286; 
} 

言語標準は、この場合のアトミック性については何も言っていません。整数代入はアトミックかもしれませんし、そうでないかもしれません。非原子演算は何の保証もしないので、Cの定義では通常の整数代入は非原子です。

実際、ターゲット・プラットフォームについてはより多くのことが知られています。例えば、今日のx86、x64、Itanium、SPARC、ARM、PowerPCプロセッサでは、ターゲット変数が自然にアラインされている限り、通常の32ビット整数代入はアトミックであることは誰でも知っています。ゲーム業界では、この特定の保証に依存する32ビット整数代入の例をたくさん挙げることができます。

にもかかわらず、真にポータブルなCおよびC++コードを書くという、古くからの伝統があります。ポータブルCおよびC++コードは、過去、現在、仮想を問わず、ありとあらゆるコンピューティング・デバイス上で実行できるように設計されています。私自身は、メモリが先着順でしか変更できないようなマシンを設計したかったのです:

このようなマシンでは、通常の代入を実行しながら同時読み取り操作を行うことは絶対に避けたいものです。

C++11 では、実際にポータブルなアトミック ロードとストアを実行するための究極のソリューション、C++11 アトミック ライブラリが用意されています。C++11 アトミック・ライブラリを使用してアトミック・ロードとストアを実行すれば、仮想コンピュータ上で実行することも可能です。ただし、C++11 アトミック・ライブラリでは、すべての操作がアトミックであることを保証するためにミューテックスを無言で追加する必要があります。先月リリースしたMintomicライブラリもあります。これはそれほど多くのプラットフォームをサポートしていませんが、多くの以前のコンパイラで実行でき、最適化されており、ロックフリーであることが保証されています。

ゆるい原子操作

それでは、sharedValue の例の最初に戻って、Mintomic がサポートするどのプラット フォームでもすべての操作をアトミックに実行できるように Mintomic で書き換えてみましょう。まず、sharedValueをMintomicのアトミックデータ型の一つとして宣言します。

#include <mintomic/mintomic.h> 
mint_atomic64_t sharedValue = { 0 }; 

mint_atomic64_t 型は、すべてのプラットフォームでアトミック・アクセス の正しいメモリ・アライメントを保証します。例えば、ARM用のGCC 4.2コンパイラに付属しているXcode 3.2.5では、古いuint64_tが8バイトでアラインされていることが保証されていないため、これは重要です。

storeValueの場合は、mint_store_64_relaxedを呼び出す必要があります。

void storeValue() 
{ 
     mint_store_64_relaxed(&sharedValue, 0x100000002); 
} 

同様に、loadValue で mint_load_64_relaxed を呼び出します。

uint64_t loadValue() 
{ 
      return mint_load_64_relaxed(&sharedValue); 
} 

C++11 の用語を使用すると、これらの関数にはデータ競合がありません。コードが ARMv6/ARMv7、x86、x64、PowerPC のどれで実行されているかに関係なく、並行処理が実行されたときにリード/ライトティアリングが発生する可能性はまったくありません。mint_load_64_relaxedとmint_store_64_relaxedがどのように動作するのか気になる方は、両関数ともx86ではインラインのcmpxchg8b命令に拡張されています。

同様のコードはC++11で明示的に記述されています:

#include <atomic> 
  
std::atomic<uint64_t> sharedValue(0); 
  
void storeValue() 
{ 
     sharedValue.store(0x100000002, std::memory_order_relaxed); 
} 
  
uint64_t loadValue() 
{ 
     return sharedValue.load(std::memory_order_relaxed); 
} 

MintomicとC++11の例では、_relaxed接尾辞を持つ複数の識別子によって、緩やかなアトミック性が使用されていることがわかります。relaxed接尾辞は、通常のロードとストアと同様に、メモリ アクセスの順序が保証されていないことを意味します。

ルース・アトミック・ロードと非アトミック・ロードの唯一の違いは、ルース・アトミック演算がアトミック性を保証することであり、それ以外にそれを保証する違いはありません。

特に、コンパイラの並べ替えやメモリの並べ替えに起因するプロセッサ独自の影響により、先行または後続の命令の影響を受けるプログラム命令内のルース・アトミック演算は、メモリに対して依然として合法です。コンパイラは、非アトミック演算と同様に、冗長なルーズ・アトミック演算に対して最適化を実行することもできます。どう見ても、これらの演算はアトミックなままです。

並行処理が同時にメモリを共有する場合、通常のロードやストアがターゲット・プラットフォーム上ですでにアトミックであることがわかっていても、MintomicやC++11のアトミック・ライブラリ関数を常に使用するのは非常に良い練習になると思います。アトミックライブラリ関数は、この変数が並行データストアのターゲットであるというヒントのようなものです。

世界一シンプルなロックレスハッシュテーブル』がなぜMintomicライブラリ関数を使用してスレッド間で共有メモリを同時操作するのか、これでお分かりいただけたかと思います。

Read next

ファーウェイのBYODソリューション:セキュリティ、効率、体験の融合

ファーウェイは企業向けBYODモバイルオフィス・ソリューションを再導入しました。このソリューションは高速ネットワーク、セキュリティ保護、ユーザー志向のコラボレーション体験を基盤としており、企業の従業員がいつでもどこでも、どんなスマート端末からでも企業イントラネットにアクセスし、さまざまなビジネスに対応し、自分のペースで仕事をすることができます。

Feb 5, 2015 · 3 min read