zoukankan      html  css  js  c++  java
  • 浅谈线程池(下):相关试验及注意事项

    三个月,整整三个月了,我忽然发现我还有三个月前的一个小系列的文章没有结束,我还欠一个试验!线程池是.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等常用组件,无论是学习还是使用都是绝佳的范例。

  • 相关阅读:
    【WPF/WAF】使用System.Windows.Interactivity交互事件
    【Linux/CentOS】Boolean ftp_home_dir is not defined
    【笔记】使用Token做验证
    【笔记】什么是跨域请求/访问?
    MongoDB优化与一些需要注意的细节
    MongoDB中聚合工具Aggregate等的介绍与使用
    MongoDB中MapReduce介绍与使用
    Centos下MongoDB的安装与配置
    PHP使用header方式实现文件下载
    关于redis中SDS简单动态字符串
  • 原文地址:https://www.cnblogs.com/wywnet/p/9216518.html
Copyright © 2011-2022 走看看