【C++】スレッドでカウンターが合わない原因とは?|データ競合で失われる回数を実測して、mutex・atomic・スレッドごとの集計の速度差を比べる

【C++】スレッドでカウンターが合わない原因とは?|データ競合で失われる回数を実測して、mutex・atomic・スレッドごとの集計の速度差を比べる C++

4つのスレッドで、カウンターを、100万回ずつ増やしました。合計は、400万になるはずです。ところが、実際に出たのは、Debugで約100万〜160万、Releaseでは、毎回ちょうど200万でした。しかも、エラーも警告も、出ません。

複数のスレッドが、同じ変数を、同時に書き換えると、増やした分が、消えます。これが、データ競合です。この記事では、消えた回数を実測して、mutex・atomic・スレッドごとの集計という、3通りの直し方の、正しさと速さを、比べました。検証は、Visual Studio 2022付属のMSVC(19.44、x64)で、論理コア数が20のPCで行っています。

データ競合とは?

データ競合とは、

・複数のスレッドが、同じメモリを、同時に読み書きして、そのうち1つ以上が書き込みである状態。結果が、保証されない(未定義動作)

です。count++は、1行に見えますが、実際には、「読む → 1を足す → 書き戻す」の3手順です。2つのスレッドが、同時に同じ値を読んで、それぞれ1を足して、書き戻すと、2回増やしたのに、1回ぶんしか増えません。

実験:保護なしで、4スレッドから増やす

▼t1_race.cpp
#include <atomic>
#include <chrono>
#include <cstdio>
#include <cstring>
#include <mutex>
#include <thread>
#include <vector>

static const int kThreads = 4;
static const int kPerThread = 1000000;          // 1スレッドあたり100万回

static int              g_plain = 0;            // 何も保護しない
static volatile int     g_volatile = 0;         // volatile を付けただけ(排他制御には、ならない)
static int              g_mutex = 0;
static std::mutex       g_lock;
static std::atomic<int> g_atomic{ 0 };

static void PlainWorker()    { for (int i = 0; i < kPerThread; ++i) { g_plain++; } }
static void VolatileWorker() { for (int i = 0; i < kPerThread; ++i) { g_volatile = g_volatile + 1; } }
static void MutexWorker()    { for (int i = 0; i < kPerThread; ++i) { std::lock_guard<std::mutex> lk(g_lock); g_mutex++; } }
static void AtomicWorker()   { for (int i = 0; i < kPerThread; ++i) { g_atomic.fetch_add(1, std::memory_order_relaxed); } }

template <class F>
static double RunThreads(F worker)
{
    auto t0 = std::chrono::steady_clock::now();
    std::vector<std::thread> ts;
    for (int i = 0; i < kThreads; ++i) ts.emplace_back(worker);
    for (auto& t : ts) t.join();
    auto t1 = std::chrono::steady_clock::now();
    return std::chrono::duration<double, std::milli>(t1 - t0).count();
}

int main()
{
    std::printf("CPUの論理コア数 = %u / スレッド数 = %d / 1スレッドあたり %d 回 / 期待値 = %d\n\n",
                std::thread::hardware_concurrency(), kThreads, kPerThread, kThreads * kPerThread);
    std::printf("%-22s %-12s %-10s %s\n", "方法", "合計の結果", "失われた数", "時間(ms)");
    for (int run = 1; run <= 5; ++run)
    {
        g_plain = 0;
        double ms = RunThreads(PlainWorker);
        std::printf("%-22s %-12d %-10d %.1f\n", "保護なし(int)", g_plain, kThreads * kPerThread - g_plain, ms);
    }
    for (int run = 1; run <= 3; ++run)
    {
        g_volatile = 0;
        double ms = RunThreads(VolatileWorker);
        std::printf("%-22s %-12d %-10d %.1f\n", "volatile int", (int)g_volatile, kThreads * kPerThread - (int)g_volatile, ms);
    }
    for (int run = 1; run <= 3; ++run)
    {
        g_mutex = 0;
        double ms = RunThreads(MutexWorker);
        std::printf("%-22s %-12d %-10d %.1f\n", "std::mutex", g_mutex, kThreads * kPerThread - g_mutex, ms);
    }
    for (int run = 1; run <= 3; ++run)
    {
        g_atomic = 0;
        double ms = RunThreads(AtomicWorker);
        std::printf("%-22s %-12d %-10d %.1f\n", "std::atomic<int>", g_atomic.load(), kThreads * kPerThread - g_atomic.load(), ms);
    }
    return 0;
}
CPUの論理コア数 = 20 / スレッド数 = 4 / 1スレッドあたり 1000000 回 / 期待値 = 4000000

方法                 合計の結果 失われた数 時間(ms)
保護なし(int)      1073570      2926430    8.3
保護なし(int)      1246115      2753885    6.2
保護なし(int)      1258660      2741340    6.6
保護なし(int)      1596293      2403707    7.9
保護なし(int)      1180969      2819031    6.5
volatile int           1823398      2176602    8.2
volatile int           1304443      2695557    7.1
volatile int           1293961      2706039    6.6
std::mutex             4000000      0          150.0
std::mutex             4000000      0          167.2
std::mutex             4000000      0          161.7
std::atomic<int>       4000000      0          70.2
std::atomic<int>       4000000      0          67.7
std::atomic<int>       4000000      0          65.7
CPUの論理コア数 = 20 / スレッド数 = 4 / 1スレッドあたり 1000000 回 / 期待値 = 4000000

方法                 合計の結果 失われた数 時間(ms)
保護なし(int)      2000000      2000000    1.2
保護なし(int)      2000000      2000000    0.5
保護なし(int)      2000000      2000000    0.4
保護なし(int)      2000000      2000000    0.4
保護なし(int)      2000000      2000000    0.4
volatile int           1045614      2954386    2.9
volatile int           1073475      2926525    3.0
volatile int           1163581      2836419    2.9
std::mutex             4000000      0          105.6
std::mutex             4000000      0          111.2
std::mutex             4000000      0          109.8
std::atomic<int>       4000000      0          51.7
std::atomic<int>       4000000      0          53.0
std::atomic<int>       4000000      0          51.0

1つ目の行が、保護なしのintです。Debugでは、実行のたびに結果が違い、107万〜160万の間でばらつきました。期待値の400万に、遠く及びません。

一方、Releaseでは、5回とも、ちょうど200万でした。結果が毎回同じなので、「安定しているから、安全」と、錯覚しそうですが、これは、最適化によって、g_plain++の繰り返しが、「100万を足す」1回の処理に、まとめられたためと、考えられます(逆アセンブルまでは、確認していません)。4つのスレッドが、ほぼ同時に始まって、読み書きが、2つずつ重なり、結果が200万に、なったようです。計算の中身が変われば、結果も変わります。どちらにせよ、400万は、一度も出ませんでした。

【重要】volatileを付けても、直らない

「他のスレッドから、変わるかもしれない変数には、volatileを付ける」という説明を、見かけることがあります。実験では、volatile intも試しました。

実行結果の「volatile int」の行が、それです。Releaseでも、104万〜116万と、大きく失われました。volatileは、「最適化で、読み書きを、省かない」ための指定で、複数のスレッドから、安全に書き換えるための仕組みではありません。排他制御には、なりません。

直し方:mutex・atomic・スレッドごとの集計

①std::mutexで、囲む

std::mutexと、std::lock_guardで、増やす処理を、1つのスレッドずつにします。結果は、3回ともちょうど400万でした。ただし、時間は、Releaseで、約105〜111ミリ秒かかりました。

②std::atomicを使う

std::atomic<int>のfetch_addは、「読む → 足す → 書く」を、分割できない1つの操作として行います。結果は、3回とも400万で、時間は、Releaseで約51〜53ミリ秒でした。mutexの、約半分です。

③スレッドごとに数えて、最後に合計する

そもそも、同じ変数に、書かない方法です。各スレッドが、自分だけの変数に数えて、最後に、1つのスレッドで合計します。

▼t2_local.cpp
#include <chrono>
#include <cstdio>
#include <thread>
#include <vector>

static const int kThreads = 4;
static const int kPerThread = 1000000;

int main()
{
    for (int run = 1; run <= 3; ++run)
    {
        auto t0 = std::chrono::steady_clock::now();

        std::vector<long long> partial(kThreads * 16, 0);     // 隣のスレッドと、キャッシュラインを共有しないよう、間隔を空ける
        std::vector<std::thread> ts;
        for (int t = 0; t < kThreads; ++t)
        {
            ts.emplace_back([&partial, t]()
            {
                long long local = 0;                          // 各スレッドが、自分だけの変数に、数える
                for (int i = 0; i < kPerThread; ++i) local++;
                partial[t * 16] = local;                      // 最後に、1回だけ書く
            });
        }
        for (auto& t : ts) t.join();

        long long total = 0;
        for (int t = 0; t < kThreads; ++t) total += partial[t * 16];   // 合計は、1スレッドで足す

        auto t1 = std::chrono::steady_clock::now();
        std::printf("スレッドごとに数えて、最後に合計: %lld (期待値 %d)  %.3f ms\n",
                    total, kThreads * kPerThread, std::chrono::duration<double, std::milli>(t1 - t0).count());
    }
    return 0;
}
スレッドごとに数えて、最後に合計: 4000000 (期待値 4000000)  4.394 ms
スレッドごとに数えて、最後に合計: 4000000 (期待値 4000000)  0.394 ms
スレッドごとに数えて、最後に合計: 4000000 (期待値 4000000)  1.273 ms
スレッドごとに数えて、最後に合計: 4000000 (期待値 4000000)  2.557 ms
スレッドごとに数えて、最後に合計: 4000000 (期待値 4000000)  1.702 ms
スレッドごとに数えて、最後に合計: 4000000 (期待値 4000000)  1.477 ms

結果は、400万で、時間は、Releaseで約0.4〜4.4ミリ秒でした。他の2つに比べて、桁違いに速い結果です。共有する変数がないので、待つ必要が、まったくないからです(partialの要素を、16個ずつ離しているのは、隣のスレッドの書き込み領域と、近くならないようにするためです。この間隔の効果は、今回は比較していません)。

方法合計の結果時間(Release)時間(Debug)
保護なし(int)200万(毎回)/Debugは107万〜160万約0.4ms(結果が、間違い)約6〜8ms(結果が、間違い)
volatile int104万〜116万(Release)約3ms(結果が、間違い)約7〜8ms(結果が、間違い)
std::mutex400万約105〜111ms約150〜167ms
std::atomic<int>400万約51〜53ms約66〜70ms
スレッドごとに集計400万約0.4〜4.4ms約1.5〜2.6ms
4スレッド×100万回。時間は実行のたびに変わる。結果が間違っている方法の時間は、速くても意味がない

スレッドで、カウンターを増やすときの、よくある失敗例と対処法

①「たまたま合った」ので、安全だと判断する

今回の実験では、保護なしの結果が、一度も400万になりませんでしたが、スレッドの数や、処理の重さによっては、合うこともあります。合ったことは、安全の証拠にならないので、保護のない書き込みは、最初から、避けます。

②volatileで、解決したつもりになる

実験のとおり、volatileでは、結果が合いませんでした。複数のスレッドから書くなら、std::atomicか、mutexを使います。

③mutexを、何にでも使って、遅くする

カウンターのように、1つの値を増やすだけなら、atomicのほうが、約2倍速くなりました。さらに、各スレッドで集計できるなら、スレッドごとの変数が、いちばん速い方法でした。

④Debugで合っていたので、Releaseを試さない

実験のとおり、DebugとReleaseで、結果の出方が違います。データ競合は、最適化の有無で、見え方が変わるので、両方で確認してください。

注意点

  • count++は、「読む → 足す → 書く」の3手順。同時に実行すると、増やした分が消える
  • 保護なしの結果は、Debugで約107万〜160万、Releaseでは、毎回200万(いずれも期待値は400万)
  • volatileは、排他制御にならない。Releaseでも、104万〜116万と、大きく失われた
  • 直し方は、mutex(約105〜111ms)、atomic(約51〜53ms)、スレッドごとの集計(約0.4〜4.4ms)。いずれも、結果は400万
  • 結果が毎回同じでも、正しいとは限らない(Releaseの200万)
  • 今回の検証は、論理コア数20のPC、MSVC 19.44(Visual Studio 2022付属、x64)で行った。時間や失われる数は、PCや負荷で変わる

この記事のポイント

  • 複数のスレッドが、同じ変数を同時に書くと、増やした回数が消える(データ競合)
  • volatileでは、直らない。atomicかmutexを使う
  • 速さは、スレッドごとの集計 > atomic > mutexの順だった
  • DebugとReleaseで、両方確認する

数がずれたとき、いちばん怖いのは、「ときどき合う」ことです。ずれが、毎回起きれば、すぐ直せますが、たまに合うと、気づくのが遅れます。共有する変数を、そもそも作らない。作るなら、atomicかmutexで守る。この順番で考えると、迷いません。

あわせて読みたい