- SlideBoom

C言語で苦しむロックフリー入門
(仮)
熊崎宏樹
@kumagi
C言語
• CPUの息遣いを感じられる良い言語
• ロックフリーなプログラムを書くには避けては通れな
いsafe mamory reclamation問題に一番ダイレクトに
衝突する言語
• スペースの都合上、スライド上のコードはグローバ
ル変数モリモリだから真似しちゃダメ
• メモリ確保も絶対成功する前提で書いてるけど真似
しちゃダメ
• ほんとはキャストが必要な部分もスペースの都合で
省略
Stackについて
• 最初に入れた物が最後に出てくるデータ構造
• 積み重ねるようなデータの持ち方をするから
Stackと呼ばれる
• 今回話すstackがサポートするメソッドはpush()
とpop()のみとする
Stackについて
• void push(int x): x を上に積む。関数は何も返
さない物とする。
• int pop(): 最後に積んだ値を取ってくる。
push(1);
push(2);
push(3);
int x = pop();
int y = pop();
int z = pop();
// => x=3
// => y=2
// => z=1
C言語での実装
•構造体定義
•線形リストでスタック構造を表現
•普通は配列で作るが敢えて線形リスト
typedef struct node{
int data;
node* next;
} node_t;
node_t *head = NULL;
C言語での実装
void push(int x) {
// 初期化して
node_t *new_node =
(node_t*)malloc(sizeof(node_t));
new_node->data = x;
new_node->next = head;
//挿入
head = new_node;
}
C言語での実装
int pop() {
// 獲得して
node_t *got_node = head;
node_t *next_head = got_node->next;
int value = got_node->data;
free(got_node); // 解放して
return value; // 返却
}
並行処理実装
• 近年CPUコアは(中略)マルチスレッド(後略)
void* work(void*) {
for (int i = 0; i < 100; ++i) {
push(i);
}
}
int main(void) {
pthread_t t1, t2;
pthread_create(&t1, NULL, work, NULL);
pthread_create(&t2, NULL, work, NULL);
pthread_join(&t1);
pthread_join(&t2);
}
C言語での並行push実装
void push(int x) {
node_t *new_node =
(node_t*)malloc(sizeof(node_t));
new_node->data = x;
new_node->next = head;
pthread_mutex_lock(&stack_lock);
head = new_node;
pthread_mutex_unlock(&stack_lock);
}
C言語での並行pop実装
int pop() {
pthread_mutex_lock(&stack_lock);
node_t *got_node = head;
head = got_node->next;
pthread_mutex_unlock(&stack_lock);
int value = got_node->data;
free(got_node); // 解放して
return value; // 返却
}
Mutexでだいたい良い
• ぶっちゃけStackでなら一番パフォーマンスが
出る並行処理実装
• 「mutexを使え」が実用上もっとも正しくて賢く
てメンテナンスしやすい
Mutexなしでできるのでは?
• Compare And Swap命令を使えばできる!
Compare And Swap
• 指定したアドレスxが指定した値yだったら新しい値z
で書き換えるまでを不可分に行えるCPU命令
– 以下は疑似コード
int CAS(void** x, void* y, void* z) {
if (*x == y) {
**x = *z;
return 1;
} else {
return 0;
}
}
CASスピン
• CASを使って成功するまで無限ループする
コードを書けばロックが要らない!
Mutexを用いないとどうなるか
• 複数スレッドが同時に行うと
x==1
スレッドA
スレッドB
1.xを読み出す(1)
1.xを読み出す(2)
2.読んだ値に +1
2.読んだ値に +1
3.xを保存する(1)
3.xを保存する(3)
OK!
x==3
Mutexを用いないとどうなるか
• 複数スレッドが同時に行うと破綻する場合がある
x==1
スレッドA
スレッドB
1.xを読み出す(1)
1.xを読み出す(1)
2.読んだ値に +1
2.読んだ値に +1
3.xを保存する(2)
3.xを保存する(2)
数が合わない
x==2
CASの使い方例
int x = 0;
void add_unsafe() {
++x;
}
int x = 0;
void add_cas() {
for (;;) { // spin
int old_x = x;
if (CAS(&x,
old_x,
x+1)) {
break;
}
}
}
CASを使ってみよう
• CASのお陰で衝突しても破綻しない
x==1
スレッドA
スレッドB
1. xを読み出す(1)
2. 読んだ値に +1
3. 値が1なら2へCAS
4. 失敗したので再挑戦
5. xを読み出す(2)
6. 読んだ値に +1
7. 値が2なら3へCAS
1. xを読み出す(1)
2. 読んだ値に +1
3. 値が1なら2へCAS
数が合う!
x==3
Lock-free Stack push
void lock_free_push(int x) {
node_t *new_node =
(node_t*)malloc(sizeof(node_t));
new_node->data = x;
do {
node_t *old_head = head;
new_node->next = head;
} while (!CAS(&head, old_head, new_node));
}
Lock-free Stack Push
• CASによってリトライができるので衝突もセーフ
Lock-free Stack
↓ポインタ
A
Head
CAS
「Headが指している物を指したノードを作ってCAS」
Lock-free Stack
A
B
C
CAS
CAS
CAS
Head
D
失敗した!
Lock-free Stack
A
B
また失敗した!
C
CAS
Head
CAS
D
Lock-free Stack
A
Head
CAS
C
B
D
Lock-free Stack pop
int lock_free_pop() {
node_t *old_head;
for (;;) {
old_head = head;
node_t *next_head = old_head->next;
if (CAS(&head, old_head, new_head)) {
int data = old_head->data;
free(old_head);
return data;
}
}
}
Lock-free Stackからpop
A
Head
CAS
C
D
B
Lock-free Stack ABA problem
• CASは「値が一致した場合に成功する」事まで
しか確認しない。運悪く一致してしまった場合
に事故る。
Lock-free StackのABA
D
HeadがAを指してた
HeadをAからBに書
からBに書き換えれ
き換えるぞー!
るぞー!やったー!
うおおおおおー
Head
C
push(x)しよっと
もう1回pop()しよっと
Aをpop()しよっと
メモリはAでいいや
B
A
SEGV
よく言われる解決策
• Tagを付ければ解決するよ[1]
• LL/SCを使ってもいいね[1]
– LL/SCはx86系CPUでは使えない
• Double WordのCASを使って、2word目をカウ
ンタに使うとカウンタに充分なビット数が割け
るので安心
– そもそも2wordのatomicなreadが無いじゃん。
でもpushとpopの両方で増やしたら大丈夫になっ
たわ[2]
[1]2004 Maged M. Michaelら ABA Prevention Using Single-Word Instructions1
[2]The difficulty of lock-free programming: a bug in lockfree stack
Lock-free StackのABA
D
HeadがAを指してた
HeadをAからBに書
けどTag値が1じゃな
き換えるぞー!
くて4だからやり直し
うおおおおおー
Head1
Head4
Head3
Head2
C
push(x)しよっと
もう1回pop()しよっと
Aをpop()しよっと
メモリはAでいいや
B
A
大丈夫ぽい!?
だが
SEGV
Lock-free Stack pop
• TagによるABA避けをした実装
int lock_free_pop() {
node_t *old_head;
for (;;) {
old_head = head;
node_t *next_head = old_head->next;
if (CAS(&head, old_head, new_head)) {
int data = old_head->data;
free(old_head);
return data;
}
}
}
OSへ返却
D
head
C
B
A
返却したメモリ->next; を読む!
int lock_free_pop() {
node_t *old_head;
for (;;) {
old_head = head;
node_t *next_head = old_head->next;
if (CAS(&head, old_head, new_head)) {
int data = old_head->data;
free(old_head);
return data;
}
}
}
SEGV
そもそも別の解決策しかない
• メモリを適当なタイミングでfree()するのは事故の
もと
– そもそもpop()だけをlockで守る解決策もある
• この問題はガベージコレクションのある言語では
発生しない
– 全てのスレッドが参照しなくなってからfree()されるか
ら
• よし!同一の状況をCでも再現しよう
– 参照カウンタ?
• 参照時のカウンタ更新コストで死ぬ
解決策: Hazard Pointer
• どこのポインタが捨
てられたら困るかを
共有する
• 固定長のグローバル
な配列を用意する
– 1スレッドが1要素使う
• free前に他のス
レッドがそれを
使ったら待機する
volatile node_t *h_ptr[THREADS]; // global
int lock_free_pop() {
for (;;) {
h_ptr[tid] = head;
memory_fence();
if (head != h_ptr[tid]) continue;
node_t *next_head = h_ptr[tid]->next;
if (CAS(&head, h_ptr[tid], new_head)){
int data = h_ptr[tid]->data;
for (int i=0; i<THREADS;)
if (tid == i) {++i; continue}
else if (h_ptr[i] != h_ptr[tid])
++i; else sched_yield();
free(h_ptr[tid]);
return data;
}
}
}
Hazard pointer Lock-free Stack pop
volatile node_t *h_ptr[THREADS]; // global
int lock_free_pop() {
for (;;) {
h_ptr[tid] = head;
memory_fence();
if (head != h_ptr[tid]) continue;
node_t *next_head = h_ptr[tid]->next;
if (CAS(&head, h_ptr[tid], new_head)){
int data = h_ptr[tid]->data;
for (int i=0; i<THREADS;)
if (tid == i) {++i; continue}
else if (h_ptr[i] != h_ptr[tid])
++i; else sched_yield();
free(h_ptr[tid]);
return data;
}
}
}
他の全てのス
レッドが抜けるの
を待つ
D
C
B
A
volatile node_t *h_ptr[THREADS]; // global
int lock_free_pop() {
for (;;) {
h_ptr[tid] = head;
memory_fence();
if (head != h_ptr[tid]) continue;
node_t *next_head = h_ptr[tid]->next;
if (CAS(&head, h_ptr[tid], new_head)){
int data = h_ptr[tid]->data;
for (int i=0; i<THREADS;)
if (tid == i) {++i; continue}
else if (h_ptr[i] != h_ptr[tid])
++i; else sched_yield();
free(h_ptr[tid]);
return data;
解放されないので安心
}
}
}
head
patented US 20040107227
• 2010年に放棄されたとwikipediaにはあるが…
解決策:Pass the buck
• 和訳するなら「なすりつけ法」
• hazard pointerのようにglobalなhazard_ptr配
列を最初に定義するとエントリ数の需要の動
的な増減に耐えられない
• 利用する時だけhazard_ptr配列のどこを自分
のスレッドが使ってよいかをCASで取り合う技
法
• patented: US7194495 B2
– status: 認定 と書いてあるので確実に危険
解決策:RCU
• Read-Copy-Updateの略でRCU
• カーネル空間内で、参照頻度の割に更新頻
度が極端に低いデータをロックなしで保護す
る為に使っているアルゴリズム
• 書き換え側のコストがすごい事になったりす
るが実用上の問題はない
RCU Lock-free Stack push
• rcu_read_lockによって
rcuクリティカルセクショ
ンを記述する
– そのセクション内のス
レッドはプリエンプショ
ンされない
• 余計な共有メモ
リを必要としない
しread側の負荷
はかなり小さい
int lock_free_pop() {
node_t *old_head;
for (;;) {
rcu_read_lock();
old_head = head;
node_t *next_head = old_head->next;
if (CAS(&head, old_head, new_head)) {
int data = old_head->data;
rcu_read_unlock();
synchronize_rcu();
free(old_head);
return data;
}
}
}
RCU Lock-free Stack pop
• RCUでメモリ解放を遅延
int lock_free_pop() {
node_t *old_head;
for (;;) {
rcu_read_lock();
old_head = head;
node_t *next_head = old_head->next;
if (CAS(&head, old_head, new_head)) {
int data = old_head->data;
rcu_read_unlock();
synchronize_rcu();
free(old_head);
return data;
}
}
}
他の全てのス
レッドが抜けるの
を待つ
D
C
B
A
解放されないので安心!
head
int lock_free_pop() {
node_t *old_head;
for (;;) {
rcu_read_lock();
old_head = head;
node_t *next_head = old_head->next;
if (CAS(&head, old_head, new_head)) {
int data = old_head->data;
rcu_read_unlock();
synchronize_rcu();
free(old_head);
return data;
}
}
}
RCU: Grace Period
• rcuクリティカルセクション内ではプリエンプショ
ンしなくなる
– 実を言うとプリエンプションしても良い版の実装も存
在するが詳細はまだ追ってない
• synchronize_rcuで他のスレッドが最低1回ずつ
プリエンプションするのを待つ
– 古いheadを観測して走ってるスレッドを邪魔しない
RCU: Grace Period
• プリエンプションを禁じるような操作をユーザ
空間で気軽に使われると危険が危ない
– そもそもユーザに使わせるべきではない
• つまりカーネル空間ならではの解決法であり、
ユーザ空間では使えない
解決策: Quiescent-State-Based-Reclamation
• グローバルなカウントを
増やして、それをみん
なが観測した後ならfree
してOK
– read側はglobal_countを
読むだけ
• 更新が無ければキャッ
シュラインがSステートに
落ちるので最速
– write時は更新した
global_counterが他
の全スレッドに読ま
れるまで待機する
uint64_t global_count = 0;
uint64_t local_count[THREADS] = {0};
int lock_free_pop() {
node_t *old_head;
for (;;) {
local_count[TID] = global_count;
old_head = head;
node_t *next_head = old_head->next;
if (CAS(&head, old_head, new_head)) {
int data = old_head->data;
local_count[TID] =
add_and_fetch(global_count);
for (int i=0; i < THREADS;)
if (local_count[TID] <= local_count[i])
++i; // 読まれた場合だけiが進む
free(old_head);
return data;
}
}
}
速い!
参照するメモリ数nに対して
O(1)のメモリフェンスで済む
Making Lockless Synchronization Fast: Performance Implications of Memory Reclamation
解決策: Quiescent-State-Based-Reclamatfion
• 利点:
– 特許は取られてなさげ
– read側は高速
• 欠点:
– すべてのスレッドが定期的にglobal_countを読む前
提がある
• 状況によっては結構大規模な改修になる
– read側のクリティカルセクションがネストした場合、
外側のセクションは保護対象外になってしまう
• ネスト版のEpoch Based Reclamationもあるけど今回は時
間の都合で話せない
まとめ
• とても簡単なLock-free StackひとつとってもC
言語上でスレッドセーフにするのは非常に大
変
– デバッグの難しさ
– 特許の罠
– 可変スレッド数対応
– メモリバリア厳しい
– Mutex使え