理解Parallel.Invoke,创建和重用线程

时间:2017-02-17 11:06:49

标签: c# multithreading

我试图理解Parallel.Invoke如何创建和重用线程。 我运行了以下示例代码(来自MSDN,https://msdn.microsoft.com/en-us/library/dd642243(v=vs.110).aspx):

using System;
using System.Threading;
using System.Threading.Tasks;

class ThreadLocalDemo
{
        static void Main()
        {
            // Thread-Local variable that yields a name for a thread
            ThreadLocal<string> ThreadName = new ThreadLocal<string>(() =>
            {
                return "Thread" + Thread.CurrentThread.ManagedThreadId;
            });

            // Action that prints out ThreadName for the current thread
            Action action = () =>
            {
                // If ThreadName.IsValueCreated is true, it means that we are not the
                // first action to run on this thread.
                bool repeat = ThreadName.IsValueCreated;

                Console.WriteLine("ThreadName = {0} {1}", ThreadName.Value, repeat ? "(repeat)" : "");
            };

            // Launch eight of them. On 4 cores or less, you should see some repeat ThreadNames
            Parallel.Invoke(action, action, action, action, action, action, action, action);

            // Dispose when you are done
            ThreadName.Dispose();
        }
}

据我所知,Parallel.Invoke尝试在这里创建8个线程 - 每个动作一个。所以它创建第一个线程,运行第一个action,然后通过它给线程一个ThreadName。然后它创建下一个线程(获得不同的ThreadName),依此类推。

如果无法创建新线程,它将重用之前创建的其中一个线程。在这种情况下,repeat的值将为true,我们可以在控制台输出中看到这一点。

这是正确的吗?

倒数第二个评论(“启动其中的八个。在4核或更少核心上,您应该看到一些重复的ThreadNames”)意味着Invoke创建的线程对应于处理器的可用cpu线程:在4个内核上,我们有8个cpu线程,至少有一个忙(运行操作系统和东西),所以Invoke只能使用7个不同的线程,所以我们必须至少得到一个"repeat"。 / p>

我对此评论的解释是否正确?

我在我的PC上运行此代码,该处理器具有Intel®Core™i7-2860QM处理器(即4个内核,8个cpu线程)。我期望得到至少一个"repeat",但我没有。当我将Invoke改为10而不是8个动作时,我得到了这个输出:

ThreadName = Thread6
ThreadName = Thread8
ThreadName = Thread6 (repeat)
ThreadName = Thread5
ThreadName = Thread3
ThreadName = Thread1
ThreadName = Thread10
ThreadName = Thread7
ThreadName = Thread4
ThreadName = Thread9

所以我在控制台应用程序中至少有9个不同的线程。这与我的处理器只有8个线程的事实相矛盾。

所以我猜我的一些推理是错误的。 Parallel.Invoke的工作方式与我上面描述的不同吗?如果是,怎么样?

3 个答案:

答案 0 :(得分:2)

如果你将少于10个项目传递给Parallel.Invoke,并且你没有在选项中指定MaxDegreeOfParallelism(所以 - 你的情况),它将在线程池sheduler上并行运行它们rougly下面的代码:

var actions = new [] { action, action, action, action, action, action, action, action };
var tasks = new Task[actions.Length];
for (int index = 1; index < tasks.Length; ++index)
    tasks[index] = Task.Factory.StartNew(actions[index]);
tasks[0] = new Task(actions[0]);
tasks[0].RunSynchronously();
Task.WaitAll(tasks);

所以只是一个普通的Task.Factory.StartNew。如果您将查看线程池中的最大线程数

int th, io;
ThreadPool.GetMaxThreads(out th, out io);
Console.WriteLine(th);

你会看到一些大数字,比如32767.所以,Parallel.Invoke将被执行的线程数(在你的情况下)不仅限于cpu核的数量。即使在单核cpu上,它也可以并行运行8个线程。

您可能会想,为什么有些线程会被重用?因为在线程池线程上完成工作时 - 该线程返回到池并准备接受新工作。你的例子中的动作基本上根本不起作用,并且完成得非常快。因此,有时通过Task.Factory.StartNew启动的第一个线程已经完成了您的操作,并在所有后续线程启动之前返回到池中。这样线程就可以重用了。

顺便说一下,你可以在你的例子中看到(repeat)有8个动作,如果你努力的话,你可以在8个核心(16个逻辑核心)处理器上看到7个。

更新以回答您的评论。线程池调度程序不必立即创建新线程。线程池中有最小和最大线程数。如何看我上面已经显示的最大值。要查看最小号码:

int th, io;
ThreadPool.GetMinThreads(out th, out io);

此数字通常等于核心数(例如8)。现在,当您请求在线程池线程上执行新操作,并且线程池中的线程数小于最小值时 - 将立即创建新线程。但是,如果可用线程的数量大于最小值 - 在创建新线程之前将引入某些延迟(我不记得确切地说有多长时间,大约500ms)。

您在评论中添加的声明我非常怀疑可以在2-3秒内执行。对我来说,它最多执行0.3秒。因此,当线程池创建前8个线程时,在创建第9个之前有500毫秒的延迟。在那个延迟期间,前8个线程中的一些(或全部)完成了它们的工作并可用于新工作,因此不需要创建新线程并且可以重用它们。

要验证这一点,请引入更大的延迟:

static void Main()
{
    // Thread-Local variable that yields a name for a thread
    ThreadLocal<string> ThreadName = new ThreadLocal<string>(() =>
    {
        return "Thread" + Thread.CurrentThread.ManagedThreadId;
    });

    // Action that prints out ThreadName for the current thread
    Action action = () =>
    {
        // If ThreadName.IsValueCreated is true, it means that we are not the
        // first action to run on this thread.
        bool repeat = ThreadName.IsValueCreated;            
        Console.WriteLine("ThreadName = {0} {1}", ThreadName.Value, repeat ? "(repeat)" : "");
        Thread.Sleep(1000000);
    };
    int th, io;
    ThreadPool.GetMinThreads(out th, out io);
    Console.WriteLine("cpu:" + Environment.ProcessorCount);
    Console.WriteLine(th);        
    Parallel.Invoke(Enumerable.Repeat(action, 100).ToArray());        

    // Dispose when you are done
    ThreadName.Dispose();
    Console.ReadKey();
}

您将看到现在线程池必须每次都创建新线程(远远超过核心),因为它们在繁忙时无法重用先前的线程。

您还可以增加线程池中的最小线程数,如下所示:

int th, io;
ThreadPool.GetMinThreads(out th, out io);
ThreadPool.SetMinThreads(100, io);

这将消除延迟(直到创建100个线程),在上面的示例中,您将注意到。

答案 1 :(得分:1)

在幕后,线程由任务调度程序组织(并由其拥有)。任务调度程序的主要目的是尽可能地使用所有CPU核心并进行有用的工作。

在幕后,调度程序正在使用线程池,然后线程池的大小是微调在CPU内核上执行的操作的有用性的方法。

现在这需要一些分析。例如,线程切换需要CPU周期,并且它没有用处。另一方面,当一个线程在核心上执行一个任务时,所有其他任务都会停止,并且它们不在该核心上进行。我认为这是调度程序通常每个核心启动两个线程的核心原因,因此,如果一个任务需要更长的时间来完成(例如几秒钟),至少可以看到一些移动。

这个基本机制有推论。当某些任务需要很长时间才能完成时,调度程序会启动新线程来进行补偿。这意味着长时间运行的任务现在必须与短期运行的任务竞争核心。这样,短期任务将陆续完成,长期任务也将慢慢完成。

最重要的是,您对线程的观察通常是正确的,但在特定情况下并非完全正确。在具体执行许多任务时,调度程序可能会选择引发更多线程,或继续使用默认值。这就是为什么你有时会注意到线程数不同的原因。

记住游戏的目标:尽可能地利用有用的工作来利用CPU核心,同时让所有任务都移动,这样应用程序看起来就像冻结一样。从历史上看,人们曾经尝试用许多不同的技术来实现这些目标。分析表明,许多技术是随机应用的,并没有真正提高CPU利用率。该分析导致在.NET中引入任务调度程序,因此可以对微调进行一次编码并完成。

答案 2 :(得分:0)

  

因此,控制台应用程序中至少有9个不同的线程。这与我的处理器只有8个线程的事实相矛盾。

线程是一个非常重载的术语。至少可以表示:(1)用来缝制的东西;(2)一堆具有关联状态的代码,由OS句柄表示;(3)CPU的执行管道。 Thread.CurrentThread指的是(2),您提到的“处理器线程”指的是(3)。

(2)线程的存在并不取决于(3)线程的存在,并且任何特定系统上存在的(2)线程的数量在很大程度上受可用内存和OS设计的限制。 (2)线程的存在并不意味着在任何给定时间都会执行(2)线程(除非您使用可保证执行此操作的API)。

此外,如果(2)线程在某个时刻执行-暗示(2)线程与(3)线程之间的临时1:1绑定,则没有暗示该线程通常会继续执行,当然,也没有暗示如果继续执行该线程将继续在同一(3)线程上执行。

因此,即使您由于某种副作用而“抓住”了(3)线程上的(2)线程的执行,例如就像您所做的那样,在控制台输出中并不一定意味着此时还有其他(2)个线程和(3)个线程。

转到您的代码:

// If ThreadName.IsValueCreated is true, it means that we are not the
// first action to run on this thread.   <-- this refers to (2)-thread, NOT (3)-thread.

Parallel.Invoke (根据规范)不排除创建与传递给它的参数一样多的新(2)线程。创建的(2)线程的实际数量可能一直是从零到英雄,因为要调用Parallel.Invoke,必须存在一个已有的(2)线程,其中包含一些可调用此API的代码。因此,例如,根本不需要创建新的(2)线程。由Parallel.Invoke创建的(2)线程是否同时在任何特定数量的(3)线程上执行也不在您的控制范围内。

因此可以解释您看到的行为。您将(2)线程与(3)线程进行了合并,并假设Parallel.Invoke做特定的事情,实际上并不能保证这样做。引用documentation

  

不能保证操作的执行顺序或是否并行执行。

这意味着Invoke可以随意在专用(2)线程上运行操作。这就是您观察到的。