Linux上でのマルチスレッドプログラミングの注意点

その1:Ubuntu上でのmallocの性能について

Ubuntu上でマルチスレッドプログラムを開発していたところ、マルチコアCPU のマシンにも関わらずスレッド数を増やすと遅くなるという問題に遭遇しました。

あれこれ試したところ、mallocを置き換えることでスレッド数に応じて高速化するようになりました。

そこで、簡単なベンチマークでいくかのmallocの実装について性能の比較をしてみたいと思います。

比較したmallocの実装は以下のとおりです。

  • GLIBCのmalloc: Ubuntu Linux標準のもの
  • jemalloc [1] : FreeBSDのmalloc。なんとなく安心して使えそう。Firefoxでも採用されている
  • tcmalloc [1] : Googleが作った高速malloc

実験を行った環境は以下のとおりです。

OS:  Ubuntu 12.4
CPU: Intel(R) Core(TM) i7 CPU 860  @ 2.80GHz(4core HT)
GLIBC: 2.15
コンパイラ: gcc 4.6.3

実行した処理は 1, 2, 4, 8スレッドで100万回のmalloc/freeを繰り返すだけのものです。一度のmallocのサイズは1kBとしました。サイズと回数に深い理由はありません。適当です。

計測には/usr/bin/timeを利用しました。単位は秒です。

スレッド数 標準のもの jemalloc tcmalloc
1 0.61 0.34 0.24
2 3.26 0.17 0.12
4 8.26 0.10 0.07
8 10.84 0.07 0.06

非標準の実装では大体スレッド数に比例して速くなっています。それに対して、標準のmallocではスレッド数の増加によって劇的に遅くなっています。

これではせっかくマルチコアのCPUを買ってきても性能がでません。

とりあえずFreeBSDで実績があるのだから、jemallocを入れておけば幸せになれるんじゃないかなと個人的には思っています。

計測に利用したプログラムは malloc 以下にあります。

[1](1, 2) Ubuntuのパッケージにあるので適当にapt-getで入れました。

その2:Read/Writeロックの挙動について

さらに、Read/Writeロックの挙動についてです。

あるデータベースに対して並列に大量のデータの読み書きを行ったところ、データをネットワーク経由で読み書きした場合の方が、DBと同じプログラムの中から読み書きした場合よりも速いというまことに不可思議な現象に遭遇しました。

この現象を調査したところ、Read/Writeロックの挙動に原因があることがわかりました。

Read/Writeロックとは

まず、Read/Writeロックについて簡単に説明します。

データを複数のスレッドからアクセスする際に、すべて読み込み処理であれば、問題なく並列に処理することができます。そのため、読み込みと書き込み処理をきちんと区別して保護することで、読み込み処理のみ実行している場合にはデータへの並列アクセスが可能となります。

この「読み込み処理と書き込み処理を区別して保護する」機構がRead/Write ロックとなります。

Read/Writeロックでは、以下のように動作します。
  • 読み込みを保護する「読み込みロック」と書きこみを保護する「書き込みロック」がある
  • 書き込みロックを保持するスレッドがある場合には、他のスレッドは読み込みロック/書き込みロックのどちらの種類のロックも取得することができない。
  • 読み込みロックを保持するスレッドがある場合には、他のスレッドは読み込みロックのみ取得することができる。

問題点について

実は単純に上記の挙動を実装すると問題が発生します。

上記の挙動を忠実に実装すると、書き込みロックが読み込みロックの解放を待っている状態で、他の読み込みロックの取得要求があると、書き込みロックが保持されていないため、読み込みロックが取得可能することができます。

その結果、絶え間なく読み込み要求が発生すると、読み込み要求が書き込み要求よりも常に優先されて実行されます。そのため、読み取り要求が続くと書き込み要求がまったく実行されない状態になってしまいます。

最初にあげた「ネットワーク経由の方が経由しないよりも高速にDBへアクセスできた」という問題は、上記の挙動が原因でした。ネットワーク経由の場合には、通信のタイミングで読み込み要求が適当に途切れたため、順調に書き込み処理が実行できていました。しかし、ネットワークを経由しない場合には読み込み要求が途切れることがなく、書き込み要求が大幅に遅延しました。その遅延が性能低下につながっていました。

この書き込みが停止するという問題は、他のロックの解放待ちの書き込みロックがある場合には、読み込みロックを取得できないようにすることで解決することができます。

Linuxの状況

pthread_rwlock_rdlockのmanを見ると以下の記述があります。

The calling thread acquires the read lock if a writer does not hold
the lock and there are no writers blocked on the lock.

したがって、上記の問題には対応済みのように見えます。

しかし、実際に試してみるとどうでしょうか? なんと、書きこみロック待ちのスレッドがあっても、読み込みロックが取得できてしまします。

そこで pthread.h をのぞくと pthread_rwlockattr_setkind_np という非標準の関数と以下の設定できそうな値が見つかります。

enum
{
    PTHREAD_RWLOCK_PREFER_READER_NP,
    PTHREAD_RWLOCK_PREFER_WRITER_NP,
    PTHREAD_RWLOCK_PREFER_WRITER_NONRECURSIVE_NP,
    PTHREAD_RWLOCK_DEFAULT_NP = PTHREAD_RWLOCK_PREFER_READER_NP
};

READ_RWLOCK_PREFER_READER_NPがデフォルト値のようなのです。字面から書き込みを優先しそうな、PTHREAD_RWLOCK_PREFER_WRITER_NPを設定して、再度確認してみます。しかし、なぜかこれを設定しても、書きこみロック待ちのスレッドがあっても、読み込みロックが取得できてしまします。意味がわかりません。

試しに PTHREAD_RWLOCK_PREFER_WRITER_NONRECURSIVE_NP を指定して試してみると、書きこみロック待ちのスレッドがある場合にうまい具合に読み込みロックがブロックしてくれました。

検証用のコードは http://www.re.soum.co.jp/~tatuhiko/mt/rwlock/rwlock_test.c となります。このコードは pthread_rwlockattr_setkind_np へのパラメータは、ソースコードを書き換えて切り替えました。実行を行う際には、コメントアウトされている部分を適宜切り替えて行なって下さい。

まとめ

現状、Linux上でRead/Writeロックを使う場合には、PTHREAD_RWLOCK_PREFER_WRITER_NONRECURSIVE_NP の指定が必須のようです。

注釈

ただし、実はこの方法にも問題があります。しかし、その問題に対処する場合には、読み込み書き込みロックの自作が必要となります。

詳しいことは「Read/Writeロック」というキーワードで検索するとでてきます。

その3:いろんな言語のマルチコア対応状況

最近、マルチスレッド環境でのチューニング作業を行いました。その際に、Rubyではマルチスレッドであってもマルチコアが活用できないという話を耳にしたことがあったので実際に確認してみました。

Rubyだけでは味気ないため、他の言語でも確認しました。確認したのは以下のものです。

  • Java (1.7.0_05)
  • Ruby (1.8.7, 1.9.3p0)
  • Python (2.7.3, 3.2.3)
  • Perl (5.14.2)

確認方法は、1, 2, 4, 8スレッドで掛け算を 一定回数実行し、その時間で確認します。マシンのCPUは4コア(Intel Core i7 860)であるため、複数のコアを利用している場合には4スレッドまでは実行時間が伸びないはずです。

Java

それぞれのスレッドで100億回の乗算を実行しました。

スレッド数 実行時間(秒)
1 8.94
2 9.40
4 10.41
8 18.88

マルチコアで動作しているようです。

Ruby

それぞれのスレッドで10万回の乗算を実行しました。

1.8.7

スレッド数 実行時間(秒)
1 3.16
2 6.44
4 12.87
8 25.62

1.9.1

スレッド数 実行時間(秒)
1 1.76
2 3.63
4 7.13
8 14.13

スレッド数をふやすと順調に遅くなっています。

注意: Rubyの演算は自動的に多倍長に拡張されるため遅くなっていると考えています。

Python

それぞれのスレッドで10万回の乗算を実行しました。

2.7.3

スレッド数 実行時間(秒)
1 1.38
2 2.81
4 5.63
8 11.30

3.2.3

スレッド数 実行時間(秒)
1 1.34
2 7.56
4 13.94
8 28.08

スレッド数をふやすと遅くなっています。

Perl

それぞれのスレッドで1億回の乗算を実行しました。

スレッド数 実行時間(秒)
1 8.35
2 8.46
4 9.22
8 16.02

マルチコアが利用されているようです。

計測に利用したプログラムは http://www.re.soum.co.jp/~tatuhiko/mt/mc 以下にあります。

結論

Perl大勝利!!!

以上です。

記事執筆者: tatuhiko
記事公開日: 2012年9月18日