代码改变世界

浅谈线程池(下):相关试验及注意事项

2009-10-20 00:06  Jeffrey Zhao  阅读(29484)  评论(45编辑  收藏  举报

三个月,整整三个月了,我忽然发现我还有三个月前的一个小系列的文章没有结束,我还欠一个试验!线程池是.NET中的重要组件,几乎所有的异步功能依赖于线程池。之前我们讨论了线程池的作用、独立线程池的存在意义,以及对CLR线程池和IO线程池进行了一定说明。不过这些说明可能有些“抽象”,于是我们还是要通过试验来“验证”这些说明。此外,我认为针对某个“猜想”来设计一些试验进行验证是非常重要的能力,如果您这方面的能力略有不足的话,还是尽量加以锻炼并提高吧。

CLR线程的使用与创建

首先,我们准备这样一段代码:

public static void ThreadUseAndConstruction()
{
    ThreadPool.SetMinThreads(5, 5); // set min thread to 5
    ThreadPool.SetMaxThreads(12, 12); // set max thread to 12

    Stopwatch watch = new Stopwatch();
    watch.Start();

    WaitCallback callback = index =>
    {
        Console.WriteLine(String.Format("{0}: Task {1} started", watch.Elapsed, index));
        Thread.Sleep(10000);
        Console.WriteLine(String.Format("{0}: Task {1} finished", watch.Elapsed, index));
    };

    for (int i = 0; i < 20; i++)
    {
        ThreadPool.QueueUserWorkItem(callback, i);
    }
}

这段代码很简单。首先将线程池最小和最大线程数量设为5和12,然后向线程池中连续推入20个任务,每个任务都是打印出执行时的当前时间,然后等待10秒钟。那么请您思考一下,这段代码的输出是什么样的呢?

展开

高位的零我们就直接忽略了,我们只观察“秒”及以下精度的时间。对这个数据进行简单观察之后,我们发现可以把时间精确到0.5秒来描述每个时刻所发生的事情:

  1. 0秒:任务0至任务3,共计4个任务开始执行。
  2. 1至3秒:任务4至任务8依次执行,间隔为0.5秒。
  3. 3至6秒:任务8至任务11依次执行,间隔为1秒。
  4. 10秒:任务0至任务3执行完成,任务12至任务15开始执行。
  5. 11至12.5秒:每执行完一个旧任务(4至7),便立即开始一个新任务(16至19)。
  6. 13至22.5秒:剩余任务(8至19)依次结束。  

您猜对了吗?我没有猜对,因为有两点:

  • 原来最小线程数量为5时,只有4个线程可以立即执行。经过进一步尝试,最小线程数量为10时,也只有9个线程可以立即执行。
  • 原来线程池创建线程的速度并非永远是“每秒2个”,而一些资料上写着“每秒不超过2个”的确是确切的说法。

但是,我们还是验证了以下几个结论:

  • 在线程池最小线程数量的范围之内,尽可能多的任务立即执行。
  • 线程池使用使用每秒不超过2个的频率创建线程(1秒一个或0.5秒一个)。
  • 当达到线程池最大线程数时(第6秒),停止创建新线程。
  • 在旧任务执行完毕后,新任务立即执行。

当然,由于我们在这之前已经“了解”了线程池是如何工作的,因此这里得到的结果可能会有“自圆其说”的倾向在里面。要减少这个可能性,则需要设计更完整的试验来“解释”问题。您也可以顺着这一点进行更深入的探索。

线程池中的线程是“公用”的

我们没有独立创建线程,而是选择使用线程池一定有其原因。不过,我们既然使用了线程池,就有一些额外的东西值得注意。

首先,我们要明确一个观念:线程并不“属于”任何一个任务,或者说任务并不“拥有”线程。我们只是借用一个线程来做事,用完以后便会还回。也就是说,任务在执行时修改线程的信息(名称,优先级,语言文化等等)是没有意义的,此外,任务也不应该依赖线程的这些状态。还记得上篇文章中谈到的QueueUserWorkItem和UnsafeQueueUserWorkItem之间的区别吗?如果您的任务需要依赖什么东西,也请自行准备。线程池中的线程状态是不可靠的。当然,也尽量不要直接对当前线程进行其他操作。

其次,由于线程池有大小限制,在某些时候还可能出现死锁的情况:

static void WaitCallback(object handle)
{
    ManualResetEvent waitHandle = (ManualResetEvent)handle;

    for (int i = 0; i < 10; i++)
    {
        ThreadPool.QueueUserWorkItem(state =>
        {
            int index = (int)state;
            if (index == 9)
            {
                waitHandle.Set(); // release all 
            }
            else
            {
                waitHandle.WaitOne(); // wait 
            }
        }, i);
    }
}

public static void DeadLock()
{
    ManualResetEvent waitHandle = new ManualResetEvent(false);

    ThreadPool.SetMaxThreads(5, 5);
    ThreadPool.QueueUserWorkItem(WaitCallback, waitHandle);

    waitHandle.WaitOne();
}

在上面的代码中,waitHandle将永远阻塞。因为我们放入线程池的10个任务,只有最后一个会将waitHandle打开,其余任务也统统阻塞在这个waitHandle上。但是请注意,我们使用SetMaxThreads方法把最大线程数限制为5,这样第10个任务根本无法执行,从而进入了死锁。避免这个问题最简单的做法是增加最大线程数,但是这还是会产生许多无法工作的线程,造成资源的浪费。因此,最好的做法是重新设计并行算法,并且时刻记住:“不要阻塞线程池里的线程”。

如何合理而有效的使用线程(既不多也不少还不阻塞),这是并行算法中最常见的课题之一。例如,让您设计一个并行计算斐波那契数列的算法,如果您每次计算Fib(n)时,都创建两个新的任务来并行计算Fib(n - 1)和Fib(n - 2),并等待它们结束,就会造成上述的死锁(或大量线程)。如何解决这个问题?您可以观察一下.NET 4.0中新增的Task并行类库,它提供了丰富而易用的并行运算API,帮我们省去了大量的工作1

最后,便是时刻记得系统中哪些功能依赖线程池。例如ASP.NET中的请求也会使用CLR线程池,那么您是否应该使用ThreadPool?是否应该直接使用委托的异步调用?您是否应该调整线程池的最大和最小线数?这些问题没有确定答案,这需要您根据实际情况自己做判断。

CLR线程池与IO线程池

当第一次了解到.NET准备了一个CLR线程池和一个IO线程池的时后,我在想,这两者真的是没有关系的吗?他们会互相影响吗?于是我做了这么一个试验:

public static void IoThread()
{
    ThreadPool.SetMinThreads(5, 3);
    ThreadPool.SetMaxThreads(5, 3);

    ManualResetEvent waitHandle = new ManualResetEvent(false);

    Stopwatch watch = new Stopwatch();
    watch.Start();

    WebRequest request = HttpWebRequest.Create("http://www.cnblogs.com/");
    request.BeginGetResponse(ar =>
    {
        var response = request.EndGetResponse(ar);
        Console.WriteLine(watch.Elapsed + ": Response Get");

    }, null);

    for (int i = 0; i < 10; i++)
    {
        ThreadPool.QueueUserWorkItem(index =>
        {
            Console.WriteLine(String.Format("{0}: Task {1} started", watch.Elapsed, index));
            waitHandle.WaitOne();

        }, i);
    }

    waitHandle.WaitOne();
}

得到的结果是这样的:

00:00:00.0923543: Task 0 started
00:00:00.1152495: Task 2 started
00:00:00.1153073: Task 3 started
00:00:00.1152439: Task 1 started
00:00:01.0976629: Task 4 started
00:00:01.5235481: Response Get

从中可以看出,我们将CLR线程池的最大线程数量设为了5,并使用与上一例类似的做法故意“阻塞”了线程池(而只有5个任务被执行了,说明线程池的确被阻塞了),其目的便是观察在这种情况下一个IO异步请求是否能够得到正确的回复。答案是肯定的,IO异步请求的回调函数正常执行了。这意味着,虽然CLR线程池被用完了,但是似乎的确还是有一个额外的IO线程池在处理IO的异步回调。这样看来,CLR线程池和IO线程池两者并没有影响。此外,从.NET框架所设计的类库来看,的确将两者作了区分,例如:

public static class ThreadPool 
{
    public static bool GetAvailableThreads(out int workerThreads, out int completionPortThreads);
}

不过,这并不意味着CLR线程池中线程被用完之后,还是可以发起异步IO请求。例如,您可以尝试着将这个例子中的WebRequest操作放到for循环后面(确保CLR线程池中线程已经被用完了),这是您会发现BeginGetRequest方法的调用抛出了一个异常,提示您说线程池中没有多余的线程了。从这个角度这样看来,CLR线程池的确还是可能影响异步IO操作的(多谢xiongli大哥指出“这是由具体实现决定的”)——虽然这在普通应用程序中一般不会出现这个问题。

其实在IO线程池方面还可以进行其他一些试验。例如,您可以缩小IO线程池的最大线程数量,然后一下子发起多个异步IO请求,观察一下它们的回调函数执行时刻。这些不如就由您来自行完成了?

相关文章

 

注1:.NET 4.0在多线程方面进行了明显的增强,除了Task并行类库之外,也将Parallel Library并入框架之内。此外,.NET 4.0还提供了许多线程安全的并行容器,以及轻量级的CountDownLatch、SemaphoreSlim、SpinWait等常用组件,无论是学习还是使用都是绝佳的范例。