JohannPachelbel
@JohannPachelbel

Are you sure you want to delete the question?

Leaving a resolved question undeleted may help others!

ロック有無による処理時間の差の測定

解決したいこと

現在マルチスレッド勉強中で,ロック有無による処理時間の差を測定しています.以下の関数の処理時間を計測して,ロックなしの方がロックあり(for文内,for文外)に比べて処理時間が短くなると予想したのですが,異なる結果が出ています.特に,処理時間がロックあり(for文外)<ロックなしとなる理由が分からず困っています.この原因について,どなたかご教授願えないでしょうか?

測定結果(10回平均)

inLock(ロックあり(for文内))...約0.75秒
outLock(ロックあり(for文外))...約0.06秒
noLock(ロックなし)...約0.15秒

→処理時間について,ロックあり(for文外)<ロックなし<ロックあり(for文内)の結果となりました.
(予想はロックなし<ロックあり(for文外)<ロックあり(for文内))

該当するソースコード

// 外部の名前空間をインポート
using System;
using System.Threading;

// C#プログラムは通常1つ以上のクラスから構成される
class DataRaceExample
{
    // 共有変数として使用するためstatic宣言
    // 本課題では複数スレッドからのアクセスを想定
    static int sharedVariable = 0;
    const int number = 10000000; // 10^8

    // 共有オブジェクトとして使用するためstatic宣言
    // 本オブジェクトはロックのために使用
    static object lockObject = new object();

    // インスタンス作成
    // 共有変数と操作をインスタンスで共有するためstatic宣言
    static void IncrementSharedVariable_noLock()
    {
        // 100万回インクリメント
        for (int i = 0; i < number; i++)
        {
                sharedVariable++;
        }
    }

    static void IncrementSharedVariable_inLock()
    {
        // 100万回インクリメント
        for (int i = 0; i < number; i++)
        {
            // lockステートメントより同時アクセスを制御
            lock (lockObject)
            {
                sharedVariable++;
            }
        }
    }

    static void IncrementSharedVariable_outLock()
    {
        lock (lockObject)
        {
            // 100万回インクリメント
            for (int i = 0; i < number; i++)
            {
                sharedVariable++;
            }
        }

    }

    // インスタンスを作成せずに実行させるためstatic宣言
    static void Main()
    {
        var sw = new System.Diagnostics.Stopwatch();

        // 2つのスレッドを作成
        // 同じメソッドIncrementSharedVariableを実行
        Thread thread1 = new Thread(IncrementSharedVariable_inLock);
        Thread thread2 = new Thread(IncrementSharedVariable_inLock);
        Thread thread3 = new Thread(IncrementSharedVariable_inLock);
        Thread thread4 = new Thread(IncrementSharedVariable_inLock);

        /*Thread thread1 = new Thread(IncrementSharedVariable_outLock);
        Thread thread2 = new Thread(IncrementSharedVariable_outLock);
        Thread thread3 = new Thread(IncrementSharedVariable_outLock);
        Thread thread4 = new Thread(IncrementSharedVariable_outLock);*/

        /*Thread thread1 = new Thread(IncrementSharedVariable_noLock);
        Thread thread2 = new Thread(IncrementSharedVariable_noLock);
        Thread thread3 = new Thread(IncrementSharedVariable_noLock);
        Thread thread4 = new Thread(IncrementSharedVariable_noLock);*/

        sw.Start();

        // メソッドIncrementSharedVariableを同時に実行
        thread1.Start();
        thread2.Start();
        thread3.Start();
        thread4.Start();

        // Mainメソッドの終了防止のためthread1とthread2の実行が終了するまで待機
        thread1.Join();
        thread2.Join();
        thread3.Join();
        thread4.Join();

        sw.Stop();

        // 最終的なsharedVariableの値を表示(予測できない値になっている)
        Console.WriteLine("Final shared variable value: " + sharedVariable);
        Console.WriteLine("time: " + sw.Elapsed);

        // ウィンドウが閉じて値が見られなくなるのを防ぐため.文字入力まで待機
        // Console.ReadKey();
    }
}

自分で試したこと

ここに問題・エラーに対して試したことを記載してください。
・スレッド数の増加(4,8,16など)
・統合開発環境のコード最適化OFF

1

5Answer

BenchMarkdotNetで計測した結果です
noLockとoutLockのパフォーマンス順位は回によって前後します
1回のロック構文は100万回ループの前では些事であり、無視できるほどの差と言っていいと思います
ここに理由を見つける意義はあまりないように感じます

--1回目のベンチマーク
| Method                          | Mean      | Error     | StdDev   | Min       | Max       |
|-------------------------------- |----------:|----------:|---------:|----------:|----------:|
| IncrementSharedVariable_noLock  |  20.98 ms |  2.314 ms | 0.127 ms |  20.83 ms |  21.06 ms |
| IncrementSharedVariable_inLock  | 212.40 ms | 53.687 ms | 2.943 ms | 209.20 ms | 214.99 ms |
| IncrementSharedVariable_outLock |  20.16 ms |  8.930 ms | 0.490 ms |  19.60 ms |  20.51 ms |
--2回目のベンチマーク
| Method                          | Mean      | Error    | StdDev   | Min       | Max       |
|-------------------------------- |----------:|---------:|---------:|----------:|----------:|
| IncrementSharedVariable_noLock  |  20.76 ms | 1.994 ms | 0.109 ms |  20.64 ms |  20.83 ms |
| IncrementSharedVariable_inLock  | 215.85 ms | 4.775 ms | 0.262 ms | 215.57 ms | 216.09 ms |
| IncrementSharedVariable_outLock |  20.75 ms | 3.105 ms | 0.170 ms |  20.56 ms |  20.90 ms |

ベンチマークは自力で正しく行うのは非常に困難なので、ベンチマークソフトを使うことをお勧めします。
NugetでBenchmarkDotNetをインストールすることで簡単に測定できます。

4Like

「outLock(ロックあり(for文外))」の方は、コンパイラから見れば、"ロックを取得してから「同じ変数について単純にnumber回インクリメントする」という処理が完了するまで絶対にロックが解除されない"ことが明らかです。

このため、「同じ変数についてnumber回インクリメント」の部分が最適化されて高速になっていると考えられます。

一方、noLockの方は、複数のスレッドによるデータ競合が発生しうるため、最適化できません。

1Like

c#、dotnetの最適化で除去されなかった時(inc [rbx]; dec ecx; jnzみたいなコードが出た時)の話ですが、複数プロセッサがある場合ですが、キャッシュ競合(sharing)効果により遅くなることはあります。
lockのばあい同時に動いているスレッドは1つしかないので、メモリ書き込みは実際にはL1までで止まりますが、nolockはあるプロセッサが書き込むと、ほかのプロセッサのキャッシュは無効になることによります(l3やdramまで行ったり、バススヌープで値を待つ必要がある)
https://articlesforengineers.blogspot.com/2023/07/true-sharing-and-false-sharing.html
https://ja.wikipedia.org/wiki/%E3%83%90%E3%82%B9%E3%82%B9%E3%83%8C%E3%83%BC%E3%83%94%E3%83%B3%E3%82%B0

1Like

全てが予想です。

ロックを取るときって、
(1)ロックが取られてて待つ
(2)ロックが取られてなくて取ろうとして取れなくて待つ
(3)ロックが取られてなくて取ろうとして取れた
の3つがある気がします。

ロックを取ろうとするコストは高いと思うので、inlockよりもoutlockの方が速くなる可能性すらあるのではないでしょうか?
ようはメモリを一瞬見るだけで諦められる場合と、実際に他のスレッドと戦ってロックを取ろうとする場合では、時間に雲泥の差があるのではないかと…

とりあえずそのまま動くコードだけ貼っておきます。

using System;
using System.Threading;
class DataRaceExample
{
    static int sharedVariable = 0;
    const int number = 10000000; // 10^8
    static object lockObject = new object();
    static void IncrementSharedVariable_noLock()
    {
        for (int i = 0; i < number; i++)
        {
            sharedVariable++;
        }
    }
    static void IncrementSharedVariable_inLock()
    {
        for (int i = 0; i < number; i++)
        {
            lock (lockObject)
            {
                sharedVariable++;
            }
        }
    }
    static void IncrementSharedVariable_outLock()
    {
        lock (lockObject)
        {
            for (int i = 0; i < number; i++)
            {
                sharedVariable++;
            }
        }

    }
    static void Main()
    {
        var funcs = new ThreadStart[] {
            IncrementSharedVariable_noLock,
            IncrementSharedVariable_inLock,
            IncrementSharedVariable_outLock,
        };
        var sw = new System.Diagnostics.Stopwatch();
        const int COUNT = 5;
        const int THREAD_MAX = 8;
        foreach (var f in funcs)
        {
            for (int threadCount = 2; threadCount <= THREAD_MAX; ++threadCount)
            {
                sw.Reset();
                for (int i = 0; i < COUNT; ++i)
                {
                    var threads = new Thread[threadCount];
                    for (int j = 0; j < threads.Length; ++j)
                    {
                        threads[j] = new Thread(f);
                    }
                    sharedVariable = 0;
                    sw.Start();
                    foreach (var t in threads)
                    {
                        t.Start();
                    }
                    foreach (var t in threads)
                    {
                        t.Join();
                    }
                    sw.Stop();
                }
                Console.WriteLine($"{f.Method.Name}[threads={threadCount}] value: {sharedVariable} time(avg): {sw.Elapsed / COUNT}");
            }
        }
    }
}

.NET5のデバッグ版を使ってうちで動作させた結果

IncrementSharedVariable_noLock[threads=2] value: 10713521 time(avg): 00:00:00.0875319
IncrementSharedVariable_noLock[threads=3] value: 13212729 time(avg): 00:00:00.1561674
IncrementSharedVariable_noLock[threads=4] value: 14050556 time(avg): 00:00:00.2146809
IncrementSharedVariable_noLock[threads=5] value: 15618147 time(avg): 00:00:00.2955025
IncrementSharedVariable_noLock[threads=6] value: 15748518 time(avg): 00:00:00.3682711
IncrementSharedVariable_noLock[threads=7] value: 14458217 time(avg): 00:00:00.4510688
IncrementSharedVariable_noLock[threads=8] value: 14510954 time(avg): 00:00:00.5212755
IncrementSharedVariable_inLock[threads=2] value: 20000000 time(avg): 00:00:00.7385264
IncrementSharedVariable_inLock[threads=3] value: 30000000 time(avg): 00:00:03.0126780
IncrementSharedVariable_inLock[threads=4] value: 40000000 time(avg): 00:00:02.4893660
IncrementSharedVariable_inLock[threads=5] value: 50000000 time(avg): 00:00:04.0310241
IncrementSharedVariable_inLock[threads=6] value: 60000000 time(avg): 00:00:05.0140120
IncrementSharedVariable_inLock[threads=7] value: 70000000 time(avg): 00:00:05.5686664
IncrementSharedVariable_inLock[threads=8] value: 80000000 time(avg): 00:00:08.6242149
IncrementSharedVariable_outLock[threads=2] value: 20000000 time(avg): 00:00:00.0606259
IncrementSharedVariable_outLock[threads=3] value: 30000000 time(avg): 00:00:00.0921074
IncrementSharedVariable_outLock[threads=4] value: 40000000 time(avg): 00:00:00.1249926
IncrementSharedVariable_outLock[threads=5] value: 50000000 time(avg): 00:00:00.1476410
IncrementSharedVariable_outLock[threads=6] value: 60000000 time(avg): 00:00:00.1748735
IncrementSharedVariable_outLock[threads=7] value: 70000000 time(avg): 00:00:00.2045862
IncrementSharedVariable_outLock[threads=8] value: 80000000 time(avg): 00:00:00.2328827

多分2スレッドでロックに必要なループ回数を増減させて比較すると、分かるのではないでしょうか?


やってみました。

using System;
using System.Threading;
class DataRaceExample
{
    static int sharedVariable = 0;
    const int number = 10000000; // 10^8
    static object lockObject = new object();
    static void Func(object perlockObj)
    {
        int perlock = (int)perlockObj;
        int outcount = number / perlock;
        for (int i = 0; i < outcount; i++)
        {
            lock (lockObject)
            {
                for (int j = 0; j < perlock; j++)
                {
                    sharedVariable++;
                }
            }
        }
    }
    static void Main()
    {
        var sw = new System.Diagnostics.Stopwatch();
        const int COUNT = 5;
        for (int perlock = number; perlock > 0; perlock /= 10)
        {
            sw.Reset();
            for (int i = 0; i < COUNT; ++i)
            {
                var threads = new Thread[] {new Thread(Func), new Thread(Func) };
                sharedVariable = 0;
                sw.Start();
                foreach (var t in threads)
                {
                    t.Start(perlock);
                }
                foreach (var t in threads)
                {
                    t.Join();
                }
                sw.Stop();
            }
            Console.WriteLine($"[perlock={perlock, 9}] value: {sharedVariable} time(avg): {sw.Elapsed / COUNT}");
        }
    }
}

同条件の結果です。

[perlock= 10000000] value: 20000000 time(avg): 00:00:00.0590401
[perlock=  1000000] value: 20000000 time(avg): 00:00:00.0621533
[perlock=   100000] value: 20000000 time(avg): 00:00:00.0600942
[perlock=    10000] value: 20000000 time(avg): 00:00:00.0609011
[perlock=     1000] value: 20000000 time(avg): 00:00:00.0666408
[perlock=      100] value: 20000000 time(avg): 00:00:00.0788215
[perlock=       10] value: 20000000 time(avg): 00:00:00.1116319
[perlock=        1] value: 20000000 time(avg): 00:00:00.8721587

ここまでやってみて考えた結論は、ロック内でする処理が軽すぎてロックにかかるコストの方が高い。特にスレッド切り替えはコストが高いと思うのでその頻度が高いとロックにかかる時間の方が支配的になるため、インクリメント処理のスレッド化による時間短縮効果(実際にはsharedVariableをインクリメントするので、そもそも時間短縮効果がほぼないと思う)をはるかに超えたのだと思う。

全て予想ですが。

1Like

今はもう非同期プログラミングに Thread という原始的なクラスを使うのではなく、async/await を使う時代なのですが(以下の記事参照。Figure 3 だけでも見てください)、評価・測定の仕方を async/await を使ったコードで行うように考え直すことはできませんか?

第1回 .NET開発における非同期処理の基礎と歴史
https://atmarkit.itmedia.co.jp/fdotnet/chushin/masterasync_01/masterasync_01_02.html

0Like

Your answer might help someone💌