アトミックであるとはどういうことか

2020年1月19日

アトミックとは、原子という意味の「アトム」に由来するソフトウェア用語です。ある操作がアトミックであるといえば、その操作は分かつことができないという意味になります。操作の不可分性をうまく言い表している用語だと思います。

組み込みプログラマーは誰もが、この用語を使うかどうかはともかく、心のどこかでアトミック性というものを意識しておく必要があります。割り込みという名の悪魔が、あなたを混乱に陥れるチャンスを常に狙っているからです。

失敗例

操作のアトミック性を特に意識しなければならないのは、絶えず動いている値を抜き取ってくるとき、あるいはそれに変化を加えるときです。次の例を見れば、この種のバグがいかにレアで、再現が困難なものであるかがご理解いただけると思います。

値を抜き取ってくるとき

絶えず動いている値の簡単な例として、時刻のことを考えます。あるプログラムでは、時刻情報として次のような構造体を使っているものとします。

class Time
{
  int hour;    // 0~23 を表す
  int minute;  // 0~59 を表す
};

Time g_now;

グローバル変数の g_now は常に現在時刻を格納しています。しかし大抵の開発環境では、次のような極めて単純な操作が、アトミックにはなりません。

Time t = g_now;

構造体のコピーは、およそ CPU の1命令で成し得る操作ではないからです。そして、この操作がアトミックでないとどうなるか。運悪く時刻の変わり目に当たると、バグになります。

hourminute 
1159 
1159 
1159→ ここで hour を取得
1200→ ここで minute を取得
1200 
1200 

11時59分でも12時00分でも問題がないのに、よりによって11時00分という値が取れてしまいました。ソースコード上では1行でも、このようなことが起こるのです。私の職場ではこれをデータの「泣き別れ」と呼んでいました。名前が付くぐらい、恐れられているということです。

値に変化を加えるとき

ビット操作も、アトミックでない操作の典型です。これもまた、ソースコード上は1行であっても、CPU の命令レベルでは少なくとも次の操作に展開されるからです。

  1. 値を読む
  2. ビットを変化させる
  3. 値を書き戻す

このことは、出力ポートの上げ下げで問題になる場合があります。たとえばあるレジスタのビットを立てようとするのと同時に、運悪く他のタスクが別のビットを立ててしまったときなどです。

タスクA タスクB
1. 値を読む 0x00 
2. ビット3を立てる 0x001. 値を読む
3. 値を書き戻す 0x102. ビット0を立てる
0x013. 値を書き戻す
0x01 

本当は 0x11 になるべきところが、0x10 への変化が塗りつぶされ、0x01 になってしまいました。これも、滅多に起きないからこそ恐ろしいバグの一つです。

弱者の義務

あるタスクから見て値が絶えず動いているということは、それは自身より優先度の高いタスクによって書き換えられているか、ペリフェラルのレジスタであるかのどちらかです。

ここに一つのポイントがあります。アトミック性を意識しなければならないのは、常に割り込まれる側、つまり弱者の側であるということです。今あなたの書いているコードが強者の側なら、これに対して打てる手はありません。

対策

アトミック問題の対策については、問題領域によって取るべき手段が異なるため、とてもここに書き切れるものではありませんが、比較的単純なものをここでご紹介します。

二度読み

値を単に抜き取るだけなら、この対策が最もシンプルです。二度読みとは、厳密には「二連続一致するまで読み続ける」ということです。これだけで泣き別れは防げます。

割り込み禁止

値を書き戻すケースでは、この対策が最もシンプルかつ確実です。アトミックでない操作を「割り込み禁止」と「割り込み許可」で挟めば済むからです。

この割り込み禁止区間がシステム全体として許容されるかについては、十分に検討を重ねてください。許される限り、私はこの方法を強くお勧めします。これ以外の方法はどれをとっても複雑で、万人には理解しがたい代物だからです。

優先度の同一化

割り込む側と割り込まれる側がいるから、問題が発生するわけです。実用上の支障がないなら、処理するコードを移動して優先度の差をなくすことで、この問題を排除することも選択肢の一つです。

排他的ロード・ストア命令

割り込み禁止はかけられない、優先度も変えられないとなると、次に取り得る手段がこれです。これはとても一言では語れない話なので、いずれ別記事にします。

アトミックでない操作の例

次の操作は、アトミックではありません。うっかりすると間に割り込まれてバグの原因になりますのでご注意ください。

ビット操作

ビット演算子はとにかく怪しいと覚えてください。

a |= 0x01;
b &= ~0x02;
c ^= 0x04;

インクリメント・デクリメント

簡潔に書けるので見逃しがちですが、アトミックではありません。

++a;
--b;

論理演算子を含む式の評価

式の評価も、時には牙をむきます。私は次のコードの isA と isB の間に割り込まれてバグを出したことがあります。十分ご注意ください。

if (isA() && isB())
{
  /* 略 */
}

アトミックな操作の例

次の操作は一般にアトミックであると考えられますが、クリティカルな局面では、念のため展開された命令コードをご確認ください。

プリミティブな値のコピー

型が int、char、short、long、float のいずれかで、単一の変数であれば、その初期化や代入はまずアトミックであると考えられます。

int a = 1;
int b = a;
float x = 2.0F;
float y = x;

long long と double はアトミックでないと考えられがちですが、今日では大抵の CPU が64ビットのロード・ストア命令を備えています。よく確認してください。

ビットセット・ビットクリア

先ほどはビット操作の失敗例を挙げましたが、この問題を考慮して設計された CPU や ASIC などでは、ビット操作のためのレジスタが二重に用意されている場合があります。すなわち、セット用とクリア用です。

EVENT_CTRL_SET = 0x01;
EVENT_CTRL_CLR = 0x02;

このようなレジスタであれば、ビット操作は単なる値の代入に還元されますから、アトミックとなります。この種のレジスタにビット演算子を適用している例をよく見かけますが、非常に発見困難なバグの原因になりますので、ビット演算ではなく代入を使うよう、くれぐれもご注意ください。