初日

初日

学習演習

はじめに

アトミック操作

1つの命令は、不可分な1つの動作のように見えることがありますが、実際にはまったくそうではありません。 メモリ上の値に5を足す場合を考えてみましょう。

add qword [rel counter], 5

その裏では、プロセッサにはメモリを直接加算する方法がありません。 プロセッサはこの1つの命令を、マイクロオペレーションと呼ばれる3つの小さなステップに分解します。

  1. メモリから現在の値を読み取る。
  2. その値をレジスタ上で5を足して変更する。
  3. 結果を書き戻す。
mov rax, qword [rel counter] ; 1. load the current value
add rax, 5                   ; 2. add five
mov qword [rel counter], rax ; 3. store the result back

これは**読み取り・変更・書き込み(RMW)**と呼ばれる一般的なパターンです。

読み取り(ロード)と書き込み(ストア)は別々の出来事なので、その間には時間の隙間が生じます。 シングルコアでは、各命令がその効果を完全に及ぼしてから次の命令が実行されることが保証されているため、通常はこの隙間に気づきません。 そのためこの隙間は目に見えず、addは1つのまとまりとして動作します。

しかし、現代のCPUがシングルコアであることはほとんどなく、アプリケーションは多くのコア上で同時に、しかもコア間に順序付けがないまま動作することがよくあります。 複数のコアがまったく同じ瞬間に動作していると、あるコアのロードの後、ストアの前に、別のコアがこの隙間でcounterを読み書きできてしまいます。 2つのスレッドがそれぞれ同じ古い値をロードし、それぞれ5を足し、それぞれの結果をストアします。 加算は2回行われたのに、値は5しか増えていません。 一方の更新は、誰にも気づかれないまま失われてしまいました。

これがデータ競合であり、マルチスレッドのコードでよくある問題です。

すべての値がこのように危険にさらされるわけではありません。 各スレッドは独自のレジスタとスタックを持っているので、レジスタに保持された値や、スレッドのスタック上のローカル変数はそのスレッドだけのものであり、競合することはありません。 保護が必要なのは、上のcounterのようにスレッド間で共有しているメモリだけです。

x86-64には、命令を不可分にすることでこの問題を解決する一連の命令があります。 その命令は、それを処理するコアだけでなく、ほかのすべてのコアにとっても1つのまとまりとして動作します。 まとまりを保ち、ほかのどのコアにも分割できない操作を、アトミックと呼びます。

Note

多くのコア上で動作するアプリケーションを、マルチスレッドと呼ぶことがよくあります。 しかし、スレッドとコアは同じものではありません。 2つのスレッドは、同じコア上で互いに交互に実行されることもあれば、別々のコア上で並列に実行されることもあります。

読み取り・変更・書き込みが複数の命令に分かれていれば、OSは任意の2つの命令の間でスレッドを切り替えられるため、交互に実行されるスレッド同士でも競合は起こりえます。 しかし、_単一_の命令の内部にある隙間が問題になるのは、本当に並列なコードだけです。 OSは命令と命令の間でしかスレッドを切り替えず、1つの命令の途中で切り替えることはないため、シングルコアでは単一の命令は本質的に安全です。

_複数_のコアにまたがる真のアトミック性を提供するのが、これから説明する命令です。

アトミック交換

xchg命令は2つのオペランドを交換します。 デスティネーションオペランドはソースオペランドの以前の値と同じになり、ソースオペランドはデスティネーションオペランドの以前の値と同じになります。 概念的には、2つのmov命令が同時に起こっていると考えることができます。

いつもどおり、2つのレジスタオペランドと組み合わせることも、1つのメモリオペランドと1つのレジスタオペランドの組み合わせで使うこともできます。

mov  eax, 1
xchg dword [rdi], eax ; [rdi] = 1, eax = the old value of [rdi]
mov ecx, 2
mov edx, 3
xchg edx, ecx         ; edx = 2, ecx = 3

メモリオペランドと組み合わせて使う場合、xchgは_常に_アトミックです。

Caution

xchgは、オペランドの1つがメモリ上の場所である場合に自動的にアトミックになります。 これは、その状況ではこの操作が格段に遅くなることも意味します。

アトミック性が必要ない場合は、代わりに空いているレジスタを介して、通常のmov命令で交換してください。

lockプリフィックス

x86-64で命令をアトミックにする最も一般的な方法は、lockプリフィックスを付けることです。 これは読み取り、変更、書き込みを1つの不可分なステップにまとめます。 つまり、コアはその処理の間ずっとメモリを独占するので、ほかのコアが途中でその場所を読み書きすることはできません。

lock add qword [rel counter], 5 ; the read, the modify, and the write are one step

lockが機能するのは、デスティネーションがメモリである場合だけで、しかもそのメモリに対して読み取り・変更・書き込みを行う命令に限られます。

  1. add、sub、inc、dec、negなどの算術演算
  2. and、or、xor、notなどのビット演算
  3. bts、btr、btcというビット操作
  4. xaddやcmpxchgなど、後述するその他の専用命令
Caution

ある場所を独占し、ほかのすべてのコアを締め出すには、それなりのコストがかかります。 lockプリフィックスが付いた操作は、プリフィックスなしの同じ操作より明らかに遅く、複数のコアが同じ場所を奪い合う場合はさらに遅くなります。

このプリフィックスは、複数のスレッドから変更される可能性があるメモリに限って使うべきです。 共有されていないメモリや、読み取りしかされないメモリには使わないようにしましょう。

交換と加算

通常のlock addはメモリを更新しますが、古い値は捨ててしまいます。 古い値こそが欲しいものであることもよくあります。たとえば、各スレッドに別々の整理券番号を配る場合です。

xadd命令(xはexchange、つまり交換を意味します)は、加算をしながら以前の値を返します。 この命令は合計をデスティネーションに書き込み、元のデスティネーションの値をソースレジスタに残します。

mov  rax, 1
lock xadd qword [rdi], rax ; [rdi] = [rdi] + rax = [rdi] + 1
                           ; rax = the old value of [rdi]

lockプリフィックスを付けると、これはアトミックなフェッチ・アンド・アッドになります。 同じカウンターに対して多くのスレッドから実行すると、呼び出しごとに異なる古い値が返ります。

lock addと同様、カウンターは最終的に呼び出し回数と正確に一致します。 ただし、lock addと違って、途中の値もすべて、呼び出した相手それぞれに1つずつ返されます。

比較と交換

xaddは加算し、xchgは上書きしますが、どちらも、新しい値を現在の値に依存させ、その間に何も変わっていない場合にだけ適用する、ということはできません。 この条件付きの更新を提供するのがcmpxchg、つまり比較と交換であり、これらのプリミティブの中で最も汎用的なものです。

cmpxchg dest, srcはraxを暗黙のアキュムレータとして使い、それをdestと比較します。

  • dest == raxなら、dest = srcとなり、ZF = 1になります。
  • dest != raxなら、rax = destとなり、ZF = 0になります。

destが更新されるのは、事前にraxにロードされた期待値と等しい場合だけであることに注意してください。 この等しさが保証するのは、destが新しい値の計算に使った値をまだ保持しているということであり、古い読み取りに基づく更新が適用されることはありません。 そのためcmpxchgは、**コンペア・アンド・スワップ(CAS)**とも呼ばれるアトミックな更新の構成要素になります。

    mov rax, qword [rdi]          ; rax = the value we expect to find
.retry:
    lea rcx, [rax + 10]           ; rcx = the new value we want to install
    lock cmpxchg qword [rdi], rcx ; if [rdi] still equals rax, store rcx and set ZF
                                  ; otherwise reload rax with the current value, clear ZF
    jnz  .retry                   ; ZF is cleared, so another thread won the race. Recompute and retry

このリトライループが、ロックフリーな更新の核心です。 読み取りと比較・交換の間の隙間こそ、別のスレッドが割り込んでくるかもしれない瞬間であり、cmpxchgは古い読み取りから計算された値をストアすることを拒むことで、それに対処します。

メモリの順序付け

ここまでの操作はどれも、1つの場所だけを扱ってきました。 スレッドが複数の場所を介して協調するようになると、新しい疑問が生まれます。あるスレッドの書き込みは、ほかのスレッドからどのような順序で見えるようになるのでしょうか。 この疑問に答えるのが、プロセッサのメモリの順序付けです。

ブランチレスコードの演習では、現代のコアが命令を1つずつ黙々と処理するわけではないという考え方を学びました。 コアは多くの命令を同時に進行させ、可能なところでは先へ進んで実行します。 つまり、ある書き込みが、プログラムが示すタイミングより遅れてほかのコアから見えるようになる一方、その後の命令はすでに先へ進んでいることがあります。

x86-64は、通常のロードとストアの間で強いメモリの順序付けを維持しており、各コアでは次のようになります。

  1. ロードが、後にあるロードの後ろに並べ替えられることはない。
  2. ストアが、後にあるストアの後ろに並べ替えられることはない。
  3. ロードが、後にあるストアの後ろに並べ替えられることはない。

唯一起こりうる並べ替えは、ストアが、_異なる_アドレスに対する後続のロードより後に完了したように見える場合です。

lockプリフィックスが付いた命令、またはメモリオペランドを伴うxchgは、完全なバリアです。どちらの方向にも、それを越えて移動するものはないように見えます。 ほとんどの状況で完全な順序付けを保証するのに、これらで十分なのはそのためです。

スピンとpause

フラグを設定すると同時にその以前の状態も返す命令は、テスト・アンド・セットと呼ばれます。 これらはスピンロックの基礎として使うことができ、コアがコードのある部分を排他的にアクセスできるようにします。

2値のフラグとxchg命令を使った、全体のアルゴリズムは次のとおりです。

  1. フラグは0から始まります。
  2. ロックを獲得するには、コアがフラグの値を1と交換します。
  3. 返された値が1なら、そのロックは別のコアが_保持_しています。 現在のコアは待機し、あらためてロックの獲得を試みます。
  4. 返された値が0なら、ロックは空いていたことになります。 xchgによってフラグは1に設定され、このコアがロックを解放するまで、ほかのコアは待機します。
  5. 現在のコアが作業を終えたら、フラグを0に更新するとロックが解放されます。
acquire:
    mov  eax, 1
    xchg dword [rdi], eax ; try to take the lock; eax = its old value
    test eax, eax
    jnz  .held            ; old value was 1: someone else holds it
    ret                   ; old value was 0: the lock is ours
.held:
    pause                 ; wait before trying again
    jmp  acquire

待機ループ内のpause命令は、コードの計算結果を変えるものではありません。 これは、これがスピンウェイトであることをプロセッサに伝えるヒントです。 するとCPUは、待機中のスレッドの消費電力を抑え、同じコアを共有する兄弟スレッドに処理を譲ることができます。 pauseのないスピンループも正しく動作しますが、無駄が多いだけです。

スレッドは作業を終えると、movを使って0をそのままストアするだけでロックを解放できます。 x86-64ではロードもストアも後続のストアの後ろに並べ替えられることがないため、mov以外に必要なものはありません。 前にあるアクセスを追い越すことのないストアは、リリースの順序付けを持つと言います。x86では、通常のストアはすべてこれを持っています。

Note

メモリ上の場所をアトミックにテストして設定する命令は、スピンロックに使うことができます。 たとえば、xchgの代わりにlock btsを使えば、特定のビットがすでにセットされているかを確認しながら、そのビットをセットできます。

btsが変更するCFのようなフラグは、レジスタであるrflagsの一部であることに注意してください。 つまり、フラグは各スレッドに固有のものです。

説明

Grand Marqueeは初日の夜を迎え、スタッフ全員が一斉に働いています。 観客はどの扉からもなだれ込み、いくつもの売り場の窓口が同じチケットロールから売り、プレミア上映は満席に近づいています。そして映写室では、リールを回し続けなければなりません。

スタッフが共有するものはすべて、あるメモリアドレスに置かれています。テストは複数のスレッドから同時にあなたの関数を呼び出します。 値を読み、変更し、書き戻すという手順を別々に行うと、この負荷の下では更新が失われてしまいます。そのため、どの関数も更新をアトミックに行う必要があります。

この演習で共有する値は、すべて64ビットの符号付き整数です。

タスクは5つあります。

Note

この演習での更新は、_アトミック_な命令で行う必要があります。 テストはあなたの関数を並行して実行し、更新が失われていないかを確認します。そのため、アトミックでない読み取り・変更・書き込みは、テストに失敗する可能性が非常に高いです。

1. 入場者数を数える

扉は開いていて、どの扉にも案内係がいてチケットをもいでいます。 通されたグループは、1つの共有された入場者数の集計に数えられます。どの扉でも同時に数えているので、館内の人数はそれでも正しく出なければなりません。

admit_group関数を実装して、グループの人数をアトミックに集計へ加えます。

この関数は、次の順番で引数を取ります。

  • admissions: 共有された入場者数の集計のメモリアドレスで、64ビットの符号付き整数です。
  • group_size: グループの人数で、64ビットの符号付き整数です。常に0より大きい値になります。
admissions = 120, group_size = 4
// admissions is now 124

テストは、複数の扉から同時にグループを入場させます。 最終的な集計は、通したすべてのグループの合計と同じでなければなりません。

この関数に戻り値はありません。 集計はその場で更新してください。

2. リールを交換する

映写機に取り付けられるリールは1つだけです。 次のリールを取り付け、今のリールを取り外すのは、1つの動作でなければなりません。映写技師が2人同時に機械の前にいると、別々の手順では、同じリールを2人に渡してしまったり、床に落としてしまったりするかもしれません。

change_reel関数を実装して、次のリールをアトミックに取り付け、それまで取り付けられていたリールを返します。

この関数は、次の順番で引数を取ります。

  • mounted: 取り付けられているリールの識別子のメモリアドレスで、64ビットの符号付き整数です。
  • next_reel: 取り付けるリールの識別子で、64ビットの符号付き整数です。
mounted = 7, next_reel = 9
// => 7, and mounted is now 9

この関数は、それまで取り付けられていたリールの識別子を、64ビットの符号付き整数として返します。 テストでは、複数のスレッドが同じマウントを通して同時にリールを交換し、渡したリールがそれぞれちょうど1回ずつ返ってくることを確認します。

3. チケットを売る

どの窓口も、1つの共有された販売枚数から売っています。 扉のときのように数を足すだけでは、ここでは足りません。それぞれの販売は、自分に割り当てられた番号も知る必要があります。 2つの販売が同じ番号を共有してはいけませんし、混雑が終わったあと、数は売れたチケットの枚数とぴったり同じでなければなりません。

sell_ticket関数を実装して、数にアトミックに1を足し、たった今売れたチケットの番号を返します。 チケット番号は、この販売の_後_の数です。数が41だったとき、この販売はチケット42になります。

この関数は、次の引数を取ります。

  • tickets_sold: 共有された販売枚数のメモリアドレスで、64ビットの符号付き整数です。
tickets_sold = 0
// => 1, and tickets_sold is now 1

tickets_sold = 41
// => 42, and tickets_sold is now 42

この関数は、チケット番号を64ビットの符号付き整数として返します。 テストでは、複数の窓口から同時に販売します。返される番号はすべて異なっていなければなりませんし、最終的な数は販売回数と同じでなければなりません。

4. プレミア上映を満席にする

プレミア上映には決まった数の座席があり、どの窓口もその席を売っています。 販売は、席がまだ残っている間だけ通すことができ、上映は絶対に売り越してはいけません。どの窓口も同じ瞬間に最後の1席を奪い合うときでさえ、です。 数を確認してから足すという2つの別々の手順には、その間に隙間が生まれます。そのため、確認と更新は一緒に成功するか、一緒に失敗するかのどちらかでなければなりません。

claim_seat関数を実装して、席がまだ空いているときに、次の席をアトミックに確保します。 席を確保できたときは、その席の番号を返します。番号は、この確保の後の、確保済みの席の数です。 上映が満席のときは0を返し、数は変えません。

この関数は、次の順番で引数を取ります。

  • seats_taken: すでに確保された席の数のメモリアドレスで、64ビットの符号付き整数です。capacityを超えることはありません。
  • capacity: 上映の座席数で、64ビットの符号付き整数です。常に0より大きい値になります。
seats_taken = 0, capacity = 150
// => 1, and seats_taken is now 1

seats_taken = 82, capacity = 150
// => 83, and seats_taken is now 83

seats_taken = 149, capacity = 150
// => 150, and seats_taken is now 150

seats_taken = 150, capacity = 150
// => 0, and seats_taken stays 150

この関数は、席の番号を、上映が満席のときは0を、64ビットの符号付き整数として返します。

5. 映写室に気を配る

映写室に入れるのは1人だけです。 メモリ上の1つのワードが、使用中かどうかを表します。0なら空き、1なら使用中です。 スタッフは一晩中やって来て、それぞれに中でやることがあります。記録帳に署名する、映写機にフィルムを通す、リールを片付ける、などです。 どんな用事であっても、映写室のルールは同じです。それを守らせるのがあなたの仕事です。1人が中に入れるのは、その用事を始めてから終えるまで、一度に1人だけです。

visit_booth関数を実装します。この関数は次のことを行います。

  1. 映写室が空くまで待ちます。
  2. 映写室を確保します。
  3. 訪問者の用事を実行します。
  4. 用事が終わったあと、戻る前に映写室を再び開けます。

映写室がほかの人に使われている間は、pauseで行儀よく待ちます。

この関数は、次の順番で引数を取ります。

  • booth: 使用中の状態を表すワードのメモリアドレスで、0か1のどちらかの64ビットの符号付き整数です。
  • task: 訪問者の用事で、引数を取らず、戻り値もない関数です。

テストでは、映写室と共有の記録帳を確認する用事を実行します。 つまり、映写室は用事の間ずっと使用中でなければなりません。また、どの訪問もその用事をちょうど1回だけ実行しなければなりません。さらに、複数の訪問者が同時に来ても、記録帳は正確でなければなりません。

Caution

訪問者の用事は、呼び出し規約に従うごく普通の関数です。

  • caller-savedレジスタはどれでも上書きされる可能性があります。そのため、用事のあとにもまだ必要になるものは、呼び出しが触れない場所に取っておいてください。
  • また、呼び出しの時点でスタックが16バイト境界に整列していることを前提としています。

どちらのルールに違反しても、チェックの失敗ではなくクラッシュとして現れることがあります。

GitHubで編集する リンクは新しいウィンドウまたはタブで開きます
x86-64 Assembly Exercism

初日を始める準備はできましたか?

Exercismに登録すれば、22個のコンセプト130個の演習、そして本物の人間によるメンタリングとともに、x86-64 Assemblyを学んでマスターできます。すべて無料です。