为什么1000个线程比几个线程快?

时间:2018-10-23 09:24:07

标签: c# .net multithreading

我有一个简单的程序,可以在2D点数组中进行线性搜索。我对1000个点进行了1000次搜索。

奇怪的是,如果生成1000个线程,则该程序的运行速度与仅跨越我拥有的CPU内核或使用Parallel.For时一样快。这与我所了解的有关创建线程的一切相反。创建和销毁线程非常昂贵,但显然在这种情况下不会。

有人可以解释为什么吗?

注意:这是一个方法论的例子;搜索算法故意不意味着做到最佳。重点是线程。

注2:我在4核i7和3核AMD上进行了测试,结果遵循相同的模式!

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Threading;

/// <summary>
/// We search for closest points.
/// For every point in array searchData, we search into inputData for the closest point, 
/// and store it at the same position into array resultData;
/// </summary>
class Program
{
    class Point
    {
        public double X { get; set; }
        public double Y { get; set; }

        public double GetDistanceFrom (Point p)
        {
            double dx, dy;
            dx = p.X - X;
            dy = p.Y - Y;
            return Math.Sqrt(dx * dx + dy * dy);
        }
    }

    const int inputDataSize = 1_000_000;
    static Point[] inputData = new Point[inputDataSize];

    const int searchDataSize = 1000;
    static Point[] searchData = new Point[searchDataSize];
    static Point[] resultData = new Point[searchDataSize];

    static void GenerateRandomData (Point[] array)
    {
        Random rand = new Random();
        for (int i = 0; i < array.Length; i++)
        {
            array[i] = new Point()
            {
                X = rand.NextDouble() * 100_000,
                Y = rand.NextDouble() * 100_000
            };
        }
    }

    private static void SearchOne(int i)
    {
        var searchPoint = searchData[i];
        foreach (var p in inputData)
        {
            if (resultData[i] == null)
            {
                resultData[i] = p;
            }
            else
            {
                double oldDistance = searchPoint.GetDistanceFrom(resultData[i]);
                double newDistance = searchPoint.GetDistanceFrom(p);
                if (newDistance < oldDistance)
                {
                    resultData[i] = p;
                }
            }
        }
    }

    static void AllThreadSearch()
    {
        List<Thread> threads = new List<Thread>();
        for (int i = 0; i < searchDataSize; i++)
        {
            var thread = new Thread(
                obj =>
                {
                    int index = (int)obj;
                    SearchOne(index);
                });
            thread.Start(i);
            threads.Add(thread);
        }
        foreach (var t in threads) t.Join();
    }

    static void FewThreadSearch()
    {
        int threadCount = Environment.ProcessorCount;
        int workSize = searchDataSize / threadCount;
        List<Thread> threads = new List<Thread>();
        for (int i = 0; i < threadCount; i++)
        {
            var thread = new Thread(
                obj =>
                {
                    int[] range = (int[])obj;
                    int from = range[0];
                    int to = range[1];
                    for (int index = from; index < to; index++)
                    {
                        SearchOne(index);
                    }
                }
                );
            int rangeFrom = workSize * i;
            int rangeTo = workSize * (i + 1);
            thread.Start(new int[]{ rangeFrom, rangeTo });
            threads.Add(thread);
        }
        foreach (var t in threads) t.Join();
    }

    static void ParallelThreadSearch()
    {
        System.Threading.Tasks.Parallel.For (0, searchDataSize, 
                index =>
                {
                    SearchOne(index);
                });
    }

    static void Main(string[] args)
    {
        Console.Write("Generatic data...  ");
        GenerateRandomData(inputData);
        GenerateRandomData(searchData);
        Console.WriteLine("Done.");
        Console.WriteLine();

        Stopwatch watch = new Stopwatch();

        Console.Write("All thread searching... ");
        watch.Restart();
        AllThreadSearch();
        watch.Stop();
        Console.WriteLine($"Done in {watch.ElapsedMilliseconds} ms.");

        Console.Write("Few thread searching... ");
        watch.Restart();
        FewThreadSearch();
        watch.Stop();
        Console.WriteLine($"Done in {watch.ElapsedMilliseconds} ms.");

        Console.Write("Parallel thread searching... ");
        watch.Restart();
        ParallelThreadSearch();
        watch.Stop();
        Console.WriteLine($"Done in {watch.ElapsedMilliseconds} ms.");

        Console.WriteLine();
        Console.WriteLine("Press ENTER to quit.");
        Console.ReadLine();
    }
}

编辑:请确保在调试器之外运行该应用程序。 VS Debugger减慢了多线程的情况。


编辑2:更多测试。

为了清楚起见,这里有更新的代码,保证我们一次运行1000次:

public static void AllThreadSearch()
{
    ManualResetEvent startEvent = new ManualResetEvent(false);
    List<Thread> threads = new List<Thread>();
    for (int i = 0; i < searchDataSize; i++)
    {
        var thread = new Thread(
        obj =>
        {
            startEvent.WaitOne();
            int index = (int)obj;
            SearchOne(index);
        });
        thread.Start(i);
        threads.Add(thread);
    }
    startEvent.Set();
    foreach (var t in threads) t.Join();
}

使用较小的数组-100K元素进行测试,结果是:

1000与8个线程

               Method |     Mean |    Error |    StdDev | Scaled |
--------------------- |---------:|---------:|----------:|-------:|
      AllThreadSearch | 323.0 ms | 7.307 ms | 21.546 ms |   1.00 |
      FewThreadSearch | 164.9 ms | 3.311 ms |  5.251 ms |   1.00 |
 ParallelThreadSearch | 141.3 ms | 1.503 ms |  1.406 ms |   1.00 |

现在,正如预期的那样,1000个线程要慢得多。并行。因为它们仍然是最好的,这也是合乎逻辑的。

但是,将数组增加到500K(即每个线程完成的工作量),事情开始看起来很奇怪:

1000 vs 8,500K

               Method |     Mean |    Error |   StdDev | Scaled |
--------------------- |---------:|---------:|---------:|-------:|
      AllThreadSearch | 890.9 ms | 17.74 ms | 30.61 ms |   1.00 |
      FewThreadSearch | 712.0 ms | 13.97 ms | 20.91 ms |   1.00 |
 ParallelThreadSearch | 714.5 ms | 13.75 ms | 12.19 ms |   1.00 |

看起来上下文切换的成本可以忽略不计。线程创建成本也相对较小。具有太多线程的唯一显着成本是内存(内存地址)丢失。仅此一项就够糟糕了。

现在,线程创建成本真的那么少吗?我们被普遍告知,创建线程是非常糟糕的,而上下文切换是有害的。

5 个答案:

答案 0 :(得分:6)

您可能要考虑应用程序如何访问内存。在最大线程的情况下,您可以有效地顺序访问内存,从缓存的角度来看,这是高效的。使用少量线程的方法更加随机,从而导致高速缓存未命中。根据CPU的不同,可以使用性能计数器来衡量L1和L2缓存的命中率/未命中率。

答案 1 :(得分:5)

我认为线程过多的真正问题(除了内存使用之外)是CPU可能很难进行自我优化,因为CPU一直在切换任务。在OP的原始基准测试中,所有线程都在执行 same 任务,因此您看不到额外线程要花那么多钱。

为了模拟执行不同任务的线程,我修改了Jodrell的原始代码的reformulation(在下面的数据中标记为“ Normal”),首先通过确保所有线程来优化内存访问可以同时在同一块内存中工作,并使用此cache blocking techniques本文中的方法使该块适合高速缓存(4mb)。然后,我“撤消”以确保每组4个线程在不同的内存块中工作。我的机器的结果(以毫秒为单位):

Intel Core i7-5500U CPU 2.40GHz (Max: 2.39GHz) (Broadwell), 1 CPU, 4 logical and 2 physical cores)
inputDataSize = 1_000_000; searchDataSize = 1000; blocks used for O/D: 10

Threads         1       2       3       4       6       8       10      18      32      56      100     178     316     562     1000
Normal(N)       5722    3729    3599    2909    3485    3109    3422    3385    3138    3220    3196    3216    3061    3012    3121
Optimized(O)    5480    2903    2978    2791    2826    2806    2820    2796    2778    2775    2775    2805    2833    2866    2988
De-optimized(D) 5455    3373    3730    3849    3409    3350    3297    3335    3365    3406    3455    3553    3692    3719    3900

对于 O ,所有线程都同时在同一块可缓存内存中工作(其中1个块= inputData的1/10)。对于 D ,对于每组4个线程,没有线程同时在同一块内存中工作。因此,基本上,在前一种情况下,inputData的访问能够利用缓存,而在后一种情况下,对4个线程的访问,inputData的访问被迫使用主内存。

在图表中更容易看到。这些图表减去了线程创建成本,并注意x轴是对数的,y轴被截断以更好地显示数据的形状。此外,将1个线程的值减半以显示理论上最佳的多线程性能:

Combined Results Normal Data Optimized Data De-optimized Data

上面的快速浏览表明,优化后的数据( O )确实比其他数据要快。它也更一致(更平滑),因为与 N 相比,它不必处理缓存丢失。正如Jodrell所建议的那样,似乎有100个线程左右为最佳状态,这是我的系统上的数字,它允许一个线程在1个时间片内完成其工作。之后,时间随着线程数线性增加(请记住,x轴在图表上具有对数刻度。)

比较正常数据和优化数据,前者参差不齐,而后者则平滑。 answer建议从缓存的角度来看,与更少的线程相比,更多的线程将更高效,在更少的线程中,内存访问可能更“随机”。下表似乎证实了这一点(注意4个线程是我的机器的最佳选择,因为它具有4个逻辑内核):

N-O

最优化的版本最有趣。最坏的情况是使用4个线程,因为它们被迫在内存的不同区域中工作,从而阻止了有效的缓存。随着线程数量的增加,系统可以在线程共享内存块时进行缓存。但是,随着线程数量的增加,上下文切换使系统更难再次缓存,结果又回到最坏的情况:

D-O

我认为最后一张图表显示了上下文切换的实际成本。在原始( N )版本中,所有线程都在执行相同的任务。结果,除了CPU时间以外,对资源的竞争有限,并且CPU能够针对工作负载优化自身(即有效地进行缓存)。如果线程都在做不同的事情,则CPU无法优化自身,并且严重影响性能。因此,导致问题的不是直接上下文切换,而是资源竞争。

在这种情况下, O (2909毫秒)和 D (3849毫秒)之间4个线程的差为940毫秒。这表示性能下降了32%。因为我的计算机具有共享的L3缓存,所以即使只有4个线程,也会出现这种性能问题。

答案 2 :(得分:4)

我很随意地重新安排您的代码以使用BenchmarkDotNet运行,看起来像这样,

using System;
using System.Collections.Generic;
using System.Threading;

using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;

namespace Benchmarks
{
    public class Point
    {
        public double X { get; set; }
        public double Y { get; set; }

        public double GetDistanceFrom(Point p)
        {
            double dx, dy;
            dx = p.X - X;
            dy = p.Y - Y;
            return Math.Sqrt(dx * dx + dy * dy);
        }
    }

    [ClrJob(baseline: true)]
    public class SomeVsMany
    {
        [Params(1000)]
        public static int inputDataSize = 1000;

        [Params(10)]
        public static int searchDataSize = 10;

        static Point[] inputData = new Point[inputDataSize];
        static Point[] searchData = new Point[searchDataSize];
        static Point[] resultData = new Point[searchDataSize];

        [GlobalSetup]
        public static void Setup()
        {
            GenerateRandomData(inputData);
            GenerateRandomData(searchData);
        }

        [Benchmark]
        public static void AllThreadSearch()
        {
            List<Thread> threads = new List<Thread>();
            for (int i = 0; i < searchDataSize; i++)
            {
                var thread = new Thread(
                    obj =>
                    {
                        int index = (int)obj;
                        SearchOne(index);
                    });
                thread.Start(i);
                threads.Add(thread);
            }
            foreach (var t in threads) t.Join();
        }

        [Benchmark]
        public static void FewThreadSearch()
        {
            int threadCount = Environment.ProcessorCount;
            int workSize = searchDataSize / threadCount;
            List<Thread> threads = new List<Thread>();
            for (int i = 0; i < threadCount; i++)
            {
                var thread = new Thread(
                    obj =>
                    {
                        int[] range = (int[])obj;
                        int from = range[0];
                        int to = range[1];
                        for (int index = from; index < to; index++)
                        {
                            SearchOne(index);
                        }
                    }
                    );
                int rangeFrom = workSize * i;
                int rangeTo = workSize * (i + 1);
                thread.Start(new int[] { rangeFrom, rangeTo });
                threads.Add(thread);
            }
            foreach (var t in threads) t.Join();
        }

        [Benchmark]
        public static void ParallelThreadSearch()
        {
            System.Threading.Tasks.Parallel.For(0, searchDataSize,
                    index =>
                    {
                        SearchOne(index);
                    });
        }

        private static void GenerateRandomData(Point[] array)
        {
            Random rand = new Random();
            for (int i = 0; i < array.Length; i++)
            {
                array[i] = new Point()
                {
                    X = rand.NextDouble() * 100_000,
                    Y = rand.NextDouble() * 100_000
                };
            }
        }

        private static void SearchOne(int i)
        {
            var searchPoint = searchData[i];
            foreach (var p in inputData)
            {
                if (resultData[i] == null)
                {
                    resultData[i] = p;
                }
                else
                {
                    double oldDistance = searchPoint.GetDistanceFrom(resultData[i]);
                    double newDistance = searchPoint.GetDistanceFrom(p);
                    if (newDistance < oldDistance)
                    {
                        resultData[i] = p;
                    }
                }
            }
        }
    }

    public class Program
    {
        static void Main(string[] args)
        {
            var summary = BenchmarkRunner.Run<SomeVsMany>();
        }
    }
}

运行基准测试时,我得到了这些结果,

  

BenchmarkDotNet = v0.11.1,操作系统= Windows 10.0.14393.2485   (1607 / AnniversaryUpdate / Redstone1)英特尔酷睿i7-7600U CPU 2.80GHz   (最大:2.90GHz)(Kaby Lake),1个CPU,4个逻辑和2个物理内核   频率= 2835938 Hz,分辨率= 352.6170 ns,计时器= TSC [主机]:   .NET Framework 4.7.2(CLR 4.0.30319.42000),64位RyuJIT-v4.7.3163.0
  Clr:.NET Framework 4.7.2(CLR 4.0.30319.42000),64位   RyuJIT-v4.7.3163.0 Job = Clr Runtime = Clr

Method               inputDataSize searchDataSize Mean       Error     StdDev      
AllThreadSearch      1000          10             1,276.53us 51.0605us 142.3364us
FewThreadSearch      1000          10             547.72us   24.8199us 70.0049us
ParallelThreadSearch 1000          10             36.54us    0.6973us  0.8564us

这些是我期望的结果,与您在问题中所声称的不同。但是,正如您在注释中正确识别的,这是因为我减小了inputDataSizesearchDataSize的值。

如果我使用原始值重新运行测试,则会得到如下结果,

Method               inputDataSize searchDataSize Mean    Error    StdDev
AllThreadSearch      1000000       1000           2.872s  0.0554s  0.0701s
FewThreadSearch      1000000       1000           2.384s  0.0471s  0.0612s
ParallelThreadSearch 1000000       1000           2.449s  0.0368s  0.0344s

这些结果支持您的问题。


FWIW,我又进行了一次测试,

Method               inputDataSize searchDataSize Mean    Error    StdDev
AllThreadSearch      20000000      40             1.972s  0.0392s  0.1045s
FewThreadSearch      20000000      40             1.718s  0.0501s  0.1477s
ParallelThreadSearch 20000000      40             1.978s  0.0454s  0.0523s

这可能有助于区分上下文切换和线程创建的成本,但最终必须兼具两者。


有一些猜测,但是这里有一些断言和结论,这是根据我们的汇总结果得出的。

创建Thread会产生一些固定的开销。当工作量很大时,开销就变得微不足道了。

操作系统和处理器体系结构一次只能运行一定数量的CPU线程。将为许多使计算机在后台运行的操作保留一定的CPU时间。与后台测试和服务无关的后台进程和服务将消耗大量的CPU时间。

即使我们有8核CPU并仅产生2个线程,我们也不能指望两个线程以完全相同的速度通过程序。

接受以上几点,无论是否通过.Net ThreadPool服务线程,都只能同时服务有限数量的线程。即使所有实例化的线程都进展到某种信号量,它们也不会一次全部到达那里,并且也不会一次全部进行。如果我们拥有的线程多于可用内核,则某些线程将必须等待才能完全前进。

每个线程将进行一定的时间片或直到其等待资源为止。

这是猜测的来源,但是当inputDataSize较小时,线程将倾向于在一个时间片内完成其工作,从而需要较少或不需要上下文切换。

inputDataSize变得足够大时,无法在一个时间片内完成工作,这使得上下文切换的可能性更大。

因此,给定searchDataSize较大的固定大小,我们有三种情况。这些方案的边界取决于测试平台的特征。

inputDataSize很小

在这里,线程创建的成本很高,AllThreadSearch的速度大大降低。 ParallelThreadSearch往往会取胜,因为它可以最大程度地减少线程创建的成本。

inputDataSize为中

创建线程的成本微不足道。至关重要的是,这项工作可以在一个时间段内完成。 AllThreadSearch利用了操作系统级别的调度,并避免了Parallel.ForFewThreadSearch中的存储桶循环的合理但显着的开销。在此区域的某个地方是AllThreadSearch的最佳选择,对于某些组合,AllThreadSearch可能是最快的选择。

inputDataSize

至关重要的是,这项工作无法在一个时间段内完成。 OS调度程序和ThreadPool都无法预期上下文切换的成本。没有一些昂贵的试探法,他们怎么可能? FewThreadSearch之所以获胜,是因为它避免了上下文切换,上下文切换的代价超过了存储桶循环的代价。


与以往一样,如果您关心性能,那么在具有代表性的工作负载和具有代表性的配置的代表性系统上,基准测试是值得的。

答案 3 :(得分:-1)

首先,您必须了解Process和Thread之间的区别,以深入了解并发的好处,以实现比顺序编程更快的结果。

进程-我们可以将其称为正在执行的程序的实例。操作系统在执行应用程序时创建不同的进程。一个应用程序可以具有一个或多个进程。进程创建对于操作系统来说是一项昂贵的工作,因为它需要在创建时提供多种资源,例如内存,寄存器,系统对象的打开句柄以进行访问,安全上下文等。

线程-它是可以计划执行的流程中的实体(可以是代码的一部分)。与进程创建不同,线程创建并不昂贵/费时,因为线程共享虚拟地址空间和该进程所属的系统资源。由于无需为创建的每个线程提供资源,因此可以提高OS的性能。

下面的图表比我的话还要详尽。 enter image description here

当线程共享资源并具有并发性时,它们可以并行运行并产生改善的结果。如果您的应用程序需要高度并行,则可以创建ThreadPool(工作线程的集合)来实现高效执行异步回调。

要纠正您的最终假设/问题,创建/销毁线程并不比创建/销毁过程昂贵,因此始终拥有“正确处理的线程代码”将有益于应用程序的性能。

答案 4 :(得分:-2)

这仅仅是因为您创建的线程不能超过cpu的容量,所以实际上在两种情况下,您创建的线程数都是相同的;您的CPU最大...