Hello World
Spiga

浅谈线程池(中):独立线程池的作用及IO线程池

2009-07-24 09:21 by 老赵, 12687 visits

上一篇文章中,我们简单讨论了线程池的作用,以及CLR线程池的一些特性。不过关于线程池的基本概念还没有结束,这次我们再来补充一些必要的信息,有助于我们在程序中选择合适的使用方式。

独立线程池

上次我们讨论到,在一个.NET应用程序中会有一个CLR线程池,可以使用ThreadPool类中的静态方法来使用这个线程池。我们只要使用QueueUserWorkItem方法向线程池中添加任务,线程池就会负责在合适的时候执行它们。我们还讨论了CLR线程池的一些高级特性,例如对线程的最大和最小数量作限制,对线程创建时间作限制以避免突发的大量任务消耗太多资源等等。

那么.NET提供的线程池又有什么缺点呢?有些朋友说,一个重要的缺点就是功能太简单,例如只有一个队列,没法做到对多个队列作轮询,无法取消任务,无法设定任务优先级,无法限制任务执行速度等等。不过其实这些简单的功能,倒都可以通过在CLR线程池上增加一层(或者说,通过封装CLR线程池)来实现。例如,您可以让放入CLR线程池中的任务,在执行时从几个自定义任务队列中挑选一个运行,这样便达到了对多个队列作轮询的效果。因此,在我看来,CLR线程池的主要缺点并不在此。

我认为,CLR线程池的主要问题在于“大一统”,也就是说,整个进程内部几乎所有的任务都会依赖这个线程池。如前篇文章所说的那样,如Timer和WaitForSingleObject,还有委托的异步调用,.NET框架中的许多功能都依赖这个线程池。这个做法是合适的,但是由于开发人员对于统一的线程池无法做到精确控制,因此在一些特别的需要就无法满足了。举个最常见例子:控制运算能力。什么是运算能力?那么还是从线程讲起吧1

我们在一个程序中创建一个线程,安排给它一个任务,便交由操作系统来调度执行。操作系统会管理系统中所有的线程,并且使用一定的方式进行调度。什么是“调度”?调度便是控制线程的状态:执行,等待等等。我们都知道,从理论上来说有多少个处理单元(如2 * 2 CPU的机器便有4个处理单元),就表示操作系统可以同时做几件事情。但是线程的数量会远远超过处理单元的数量,因此操作系统为了保证每个线程都被执行,就必须等一个线程在某个处理器上执行到某个情况的时候,“换”一个新的线程来执行,这便是所谓的“上下文切换(context switch)”。至于造成上下文切换的原因也有多种,可能是某个线程的逻辑决定的,如遇上锁,或主动进入休眠状态(调用Thread.Sleep方法),但更有可能是操作系统发现这个线程“超时”了。在操作系统中会定义一个“时间片(timeslice)”2,当发现一个线程执行时间超过这个时间,便会把它撤下,换上另外一个。这样看起来,多个线程——也就是多个任务在同时运行了。

值得一提的是,对于Windows操作系统来说,它的调度单元是线程,这和线程究竟属于哪个进程并没有关系。举个例子,如果系统中只有两个进程,进程A有5个线程,而进程B有10个线程。在排除其他因素的情况下,进程B占有运算单元的时间便是进程A的两倍。当然,实际情况自然不会那么简单。例如不同进程会有不同的优先级,线程相对于自己所属的进程还会有个优先级;如果一个线程在许久没有执行的时候,或者这个线程刚从“锁”的等待中恢复,操作系统还会对这个线程的优先级作临时的提升——这一切都是牵涉到程序的运行状态,性能等情况的因素,有机会我们在做展开。

现在您意识到线程数量意味着什么了没?没错,就是我们刚才提到的“运算能力”。很多时候我们可以简单的认为,在同样的环境下,一个任务使用的线程数量越多,它所获得的运算能力就比另一个线程数量较少的任务要来得多。运算能力自然就涉及到任务执行的快慢。您可以设想一下,有一个生产任务,和一个消费任务,它们使用一个队列做临时存储。在理想情况下,生产和消费的速度应该保持相同,这样可以带来最好的吞吐量。如果生产任务执行较快,则队列中便会产生堆积,反之消费任务就会不断等待,吞吐量也会下降。因此,在实现的时候,我们往往会为生产任务和消费任务分别指派独立的线程池,并且通过增加或减少线程池内线程数量来条件运算能力,使生产和消费的步调达到平衡。

使用独立的线程池来控制运算能力的做法很常见,一个典型的案例便是SEDA架构:整个架构由多个Stage连接而成,每个Stage均由一个队列和一个独立的线程池组成,调节器会根据队列中任务的数量来调节线程池内的线程数量,最终使应用程序获得优异的并发能力。

在Windows操作系统中,Server 2003及之前版本的API也只提供了进程内部单一的线程池,不过在Vista及Server 2008的API中,除了改进线程池的性能之外,还提供了在同一进程内创建多个线程池的接口。很可惜,.NET直到如今的4.0版本,依旧没有提供构建独立线程池的功能。构造一个优秀的线程池是一件相当困难的事情,幸运的是,如果我们需要这方面的功能,可以借助著名的SmartThreadPool,经过那么多年的考验,相信它已经足够成熟了。如果需要,我们还可以对它做一定修改——毕竟在不同情况下,我们对线程池的要求也不完全相同。

IO线程池

IO线程池便是为异步IO服务的线程池。

访问IO最简单的方式(如读取一个文件)便是阻塞的,代码会等待IO操作成功(或失败)之后才继续执行下去,一切都是顺序的。但是,阻塞式IO有很多缺点,例如让UI停止响应,造成上下文切换,CPU中的缓存也可能被清除甚至内存被交换到磁盘中去,这些都是明显影响性能的做法。此外,每个IO都占用一个线程,容易导致系统中线程数量很多,最终限制了应用程序的伸缩性。因此,我们会使用“异步IO”这种做法。

在使用异步IO时,访问IO的线程不会被阻塞,逻辑将会继续下去。操作系统会负责把结果通过某种方法通知我们,一般说来,这种方式是“回调函数”。异步IO在执行过程中是不占用应用程序的线程的,因此我们可以用少量的线程发起大量的IO,所以应用程序的响应能力也可以有所提高。此外,同时发起大量IO操作在某些时候会有额外的性能优势,例如磁盘和网络可以同时工作而不互相冲突,磁盘还可以根据磁头的位置来访问就近的数据,而不是根据请求的顺序进行数据读取,这样可以有效减少磁头的移动距离。

Windows操作系统中有多种异步IO方式,但是性能最高,伸缩性最好的方式莫过于传说中的“IO完成端口(I/O Completion Port,IOCP)”了,这也是.NET中封装的唯一异步IO方式。大约一年半前,老赵写过一篇文章《正确使用异步操作》,其中除了描述计算密集型和IO密集型操作的区别和效果之外,还简单地讲述了IOCP与CLR交互的方式,摘录如下:

当我们希望进行一个异步的IO-Bound Operation时,CLR会(通过Windows API)发出一个IRP(I/O Request Packet)。当设备准备妥当,就会找出一个它“最想处理”的IRP(例如一个读取离当前磁头最近的数据的请求)并进行处理,处理完毕后设备将会(通过Windows)交还一个表示工作完成的IRP。CLR会为每个进程创建一个IOCP(I/O Completion Port)并和Windows操作系统一起维护。IOCP中一旦被放入表示完成的IRP之后(通过内部的ThreadPool.BindHandle完成),CLR就会尽快分配一个可用的线程用于继续接下去的任务。

不过事实上,使用Windows API编写IOCP非常复杂。而在.NET中,由于需要迎合标准的APM(异步编程模型),在使用方便的同时也放弃一定的控制能力。因此,在一些真正需要高吞吐量的时候(如编写服务器),不少开发人员还是会选择直接使用Native Code编写相关代码。不过在绝大部分的情况下,.NET中利用IOCP的异步IO操作已经足以获得非常优秀的性能了。使用APM方式在.NET中使用异步IO非常简单,如下:

static void Main(string[] args)
{
    WebRequest request = HttpWebRequest.Create("http://www.cnblogs.com");
    request.BeginGetResponse(HandleAsyncCallback, request);
}

static void HandleAsyncCallback(IAsyncResult ar)
{
    WebRequest request = (WebRequest)ar.AsyncState;
    WebResponse response = request.EndGetResponse(ar);
    // more operations...
}

BeginGetResponse将发起一个利用IOCP的异步IO操作,并在结束时调用HandleAsyncCallback回调函数。那么,这个回调函数是由哪里的线程执行的呢?没错,就是传说中“IO线程池”的线程。.NET在一个进程中准备了两个线程池,除了上篇文章中所提到的CLR线程池之外,它还为异步IO操作的回调准备了一个IO线程池。IO线程池的特性与CLR线程池类似,也会动态地创建和销毁线程,并且也拥有最大值和最小值(可以参考上一篇文章列举出的API)。

只可惜,IO线程池也仅仅是那“一整个”线程池,CLR线程池的缺点IO线程池也一应俱全。例如,在使用异步IO方式读取了一段文本之后,下一步操作往往是对其进行分析,这就进入了计算密集型操作了。但对于计算密集型操作来说,如果使用整个IO线程池来执行,我们无法有效的控制某项任务的运算能力。因此在有些时候,我们在回调函数内部会把计算任务再次交还给独立的线程池。这么做从理论上看会增大线程调度的开销,不过实际情况还得看具体的评测数据。如果它真的成为影响性能的关键因素之一,我们就可能需要使用Native Code来调用IOCP相关API,将回调任务直接交给独立的线程池去执行了。

我们也可以使用代码来操作IO线程池,例如下面这个接口便是向IO线程池递交一个任务:

public static class ThreadPool
{
    public static bool UnsafeQueueNativeOverlapped(NativeOverlapped* overlapped);
}

NativeOverlapped包含了一个IOCompletionCallback回调函数及一个缓冲对象,可以通过Overlapped对象创建。Overlapped会包含一个被固定的空间,这里“固定”的含义表示不会因为GC而导致地址改变,甚至不会被置换到硬盘上的Swap空间去。这么做的目的是迎合IOCP的要求,但是很明显它也会降低程序性能。因此,我们在实际编程中几乎不会使用这个方法3

相关文章

 

注1:如果没有加以说明,我们这里谈论的对象默认为XP及以上版本的Window操作系统。

注2:timeslice又被称为quantum,不同操作系统中定义的这个值并不相同。在Windows客户端操作系统(XP,Vista)中时间片默认为2个clock interval,在服务器操作系统(2003,2008)中默认为12个clock interval(在主流系统上,1个clock interval大约10到15毫秒)。服务器操作系统使用较长的时间片,是因为一般服务器上运行的程序比客户端要少很多,且更注重性能和吞吐量,而客户端系统更注重响应能力——而且,如果您真需要的话,时间片的长度也是可以调整的。

注3:不过,如果程序中多次复用单个NativeOverlapped对象的话,这个方法的性能会略微好于QueueUserWorkItem,据说WCF中便使用了这种方式——微软内部总有那么些技巧是我们不知如何使用的,例如老赵记得之前查看ASP.NET AJAX源代码的时候,在MSDN中不小心发现一个接口描述大意是“预留方法,请不要在外部使用”。对此,我们又能有什么办法呢?

Creative Commons License

本文基于署名 2.5 中国大陆许可协议发布,欢迎转载,演绎或用于商业目的,但是必须保留本文的署名赵劼(包含链接),具体操作方式可参考此处。如您有任何疑问或者授权方面的协商,请给我留言

Add your comment

43 条回复

  1. 在别处
    *.*.*.*
    链接

    在别处 2009-07-24 09:23:00

    居然是沙发?

    不是?犹豫了,哈哈

  2. DiggingDeeply
    *.*.*.*
    链接

    DiggingDeeply 2009-07-24 09:23:00

    big sofa,待会在评论。
    楼下的各位,不好意思了。

  3. bruke[未注册用户]
    *.*.*.*
    链接

    bruke[未注册用户] 2009-07-24 09:28:00

    沙发~~今天终于抢到了

  4. 老赵
    admin
    链接

    老赵 2009-07-24 09:30:00

    不是吧,三层抢沙发的。

  5. 骑着夕阳看着猪
    *.*.*.*
    链接

    骑着夕阳看着猪 2009-07-24 09:30:00

    好地方啊~

  6. billlo[未注册用户]
    *.*.*.*
    链接

    billlo[未注册用户] 2009-07-24 09:32:00

    挑老趙的刺:
    而进程B有10个进程==>應該是10個線程

  7. www.mmic.net.cn
    *.*.*.*
    链接

    www.mmic.net.cn 2009-07-24 09:38:00

    讲的不错

  8. 老赵
    admin
    链接

    老赵 2009-07-24 09:39:00

    @billlo
    谢谢,已经改正

  9. 火星人.NET
    *.*.*.*
    链接

    火星人.NET 2009-07-24 09:40:00

    不是,我记得上篇文章上的引用,很明白的说明是:“上下两篇”的啊。
    怎么插进去一个中???赚稿费?哈哈哈

  10. lovecherry_未登录[未注册用户…
    *.*.*.*
    链接

    lovecherry_未登录[未注册用户] 2009-07-24 09:41:00

    老赵的文章就好似美女,越看越感到饥渴

  11. 老赵
    admin
    链接

    老赵 2009-07-24 09:44:00

    火星人.NET:
    不是,我记得上篇文章上的引用,很明白的说明是:“上下两篇”的啊。
    怎么插进去一个中???赚稿费?哈哈哈


    这就是计划赶不上变化啊,写到一半发现太长了,于是再拆一次。

  12. 老赵
    admin
    链接

    老赵 2009-07-24 09:45:00

    lovecherry_未登录:老赵的文章就好似美女,越看越感到饥渴


    为啥……前半句像是捧,后半句像是损呢?嘿嘿。

  13. kenny.guo
    *.*.*.*
    链接

    kenny.guo 2009-07-24 09:50:00

    放进线程池后,如何操作某个特定的线程呢?

  14. bruke[未注册用户]
    *.*.*.*
    链接

    bruke[未注册用户] 2009-07-24 10:00:00

    看了老赵的文章受益非浅啊,下什么时候出来啊

  15. 老赵
    admin
    链接

    老赵 2009-07-24 10:25:00

    bruke:看了老赵的文章受益非浅啊,下什么时候出来啊


    看情况吧,我又想回到Actor去了,赫赫。

  16. 老赵
    admin
    链接

    老赵 2009-07-24 10:25:00

    kenny.guo:放进线程池后,如何操作某个特定的线程呢?


    既然是用线程池,就不会操作里面特定的对象啊。

  17. Jerry Qian
    *.*.*.*
    链接

    Jerry Qian 2009-07-24 10:30:00

  18. 在别处
    *.*.*.*
    链接

    在别处 2009-07-24 12:32:00

    终于看到两篇能读懂的了,受益匪浅啊。老赵这什么actor暂时缓缓吧,先服务大众的好。

  19. 老赵
    admin
    链接

    老赵 2009-07-24 14:14:00

    @在别处
    这两篇也是为Actor服务的啊,赫赫。

  20. yeml[未注册用户]
    *.*.*.*
    链接

    yeml[未注册用户] 2009-07-24 14:21:00

    深刻的感受到ThreadPool的单薄啊
    今天有个工作,要去ip138查几千条我们的IP库里没有省份信息的IP地址
    如果TP的话,上次那个人的CountDownLutch都用不上,CDL初始化的时候要设定计数器,但是这个场景里面一开始是不知道要查多少条IP地址的,因为ip地址在文件里面。

    好在有SmartThreadPool啊,把任务扔到池子里,然后waitforidle就可以了
    真好

  21. yeml[未注册用户]
    *.*.*.*
    链接

    yeml[未注册用户] 2009-07-24 14:43:00

    你那个Actor没什么人气,看到那个什么pingpong就烦

  22. 老赵
    admin
    链接

    老赵 2009-07-24 14:56:00

    @yeml
    早晚会有人气的,到时候搜索引擎上一搜救都是我的文章,嘿嘿。

  23. 老赵
    admin
    链接

    老赵 2009-07-24 14:59:00

    @yeml
    其实也容易啊:每个任务都使用一个公共的,线程安全的计数器(一个int就够了),每从文件读取一个ip就加1,每查询过一个ip就减1,直到文件读完,并且计数器为零的时候解锁。
    和逻辑无关的代码也就是十几行。当然,如果开一个Semaphore就更容易了,几乎没有额外代码,只是理论上Semaphore要进入内核态,访问相对慢一些。

  24. Terry Sun
    *.*.*.*
    链接

    Terry Sun 2009-07-24 16:15:00

    给老赵提一建议,如下:
    第三段最后一句“那么还是从线程讲起吧1”,建议给这种带有小标号的关键字加上link,点击这句话时跳转到注1, 点击注1再跳转回这句。

    我的显示器足够宽,但是没那么高呀 :-)

  25. DiggingDeeply
    *.*.*.*
    链接

    DiggingDeeply 2009-07-24 17:05:00

    MSDN说:"提供一个线程池,该线程池可用于发送工作项、处理异步 I/O、代表其他线程等待以及处理计时器。",而且还说“每个进程都有一个线程池。线程池的默认大小为:每个处理器 25 个辅助线程,再加上 1000 个 I/O 完成线程。”。
    所以我认为CLR就只有一个线程池,而你说的I/O线程池是不存在的,而异步得I/O请求实际上也是线程池里的I/O 完成线程来做的。
    GetAvailableThreads(out int workerThreads, out int completionPortThreads),
    workerThreads
    The number of available worker threads.
    completionPortThreads
    The number of available asynchronous I/O threads.
    可以看到实际上线程池里的线程是按用途分为两类的。
    一般BeginInvoke和EndInvoke是用的工作线程,而像FileStream,还有WebClient里的BegingXXX和EndXXX则用的是后者。

  26. 老赵
    admin
    链接

    老赵 2009-07-24 17:05:00

    @Terry Sun
    嗯嗯,好主意……

  27. 老赵
    admin
    链接

    老赵 2009-07-24 17:27:00

    @DiggingDeeply
    一个线程池里的所有线程分两类,两种类型的线程互相不影响,不就是两个线程池吗?
    你可以尝试一下,即使使用大量UserWorkItem阻塞了CLR线程池,IO线程池还是正常工作的。
    这就说明,其实里面是两个线程池,MSDN的说法反而含糊不清,呵呵。

  28. 老赵
    admin
    链接

    老赵 2009-07-24 17:53:00

    @DiggingDeeply
    这是一小个实验代码:

    ManualResetEvent waitHandle = new ManualResetEvent(false);
    
    WebRequest request = HttpWebRequest.Create("http://www.cnblogs.com/");
    request.BeginGetResponse(ar =>
    {
        try
        {
            var response = request.EndGetResponse(ar);
            Console.WriteLine("Response Get");
        }
        catch (Exception ex)
        {
            Console.WriteLine(ex.Message);
        }
    }, null);
    
    for (int i = 0; i < 10; i++)
    {
        ThreadPool.QueueUserWorkItem((state) =>
        {
            Console.WriteLine(state);
            waitHandle.WaitOne();
        }, i);
    }
    
    waitHandle.WaitOne();
    

    我把CLR线程池最大数量设为5,这样说明只能执行5个任务,但是我推进去10个任务,每个任务都会引起阻塞,所以从第6个任务开始就不会执行了。所以只打印出来0到4。
    如果没有额外的IO线程池的话,那么Response Get字样不会显示出来。但是现在,过了一段时间之后,这行字被打印出来了。这说明,两个线程池是独立的,阻塞一个不会影响另外一个。

  29. pangxiaoliang[北京]流浪者
    *.*.*.*
    链接

    pangxiaoliang[北京]流浪者 2009-07-25 00:17:00

    @Jeffrey Zhao
    做个标记,看看大作,嘿嘿嘿~~

  30. flyingchen
    *.*.*.*
    链接

    flyingchen 2009-07-26 23:50:00

    最近一项目在用IOCP做tcp代理,刚巧理解了什么是IOCP,不然理解此文还真不容易

  31. 老赵
    admin
    链接

    老赵 2009-07-27 09:50:00

    @flyingchen
    你不是搞Java了吗?Java6的nio很恶心,在Windows底下不使用IOCP,据说Java7会改变。
    所以说Windows性能差,相当程度上就是某些人不了解Windows而搞得FUD,看着Java号称“跨平台”的东西,却故意不去追究缘由。
    IOCP这个东西,网上一搜就知道了啊,我只是简单提一下有这个东西。

  32. Henley Gao
    *.*.*.*
    链接

    Henley Gao 2009-08-05 14:00:00

    坚定的北大青鸟反对者

    老赵,有时间写一篇反对的理由,放到首页!

  33. 老赵
    admin
    链接

    老赵 2009-08-05 15:10:00

    @Henley Gao
    首页上都讨论了无数遍了,还要来?

  34. DiggingDeeply
    *.*.*.*
    链接

    DiggingDeeply 2009-08-06 21:52:00

    CLR全局就只有一个ThreadPool实例,所以是一个池子。
    而且你的代码,我看的也不是特别明白,5是哪种线程的最大值?由于两种线程的最大值和最小值不同,其各自创建和调度的方式也不同,所以两种线程不会互相阻塞,只会自相阻塞。

  35. 老赵
    admin
    链接

    老赵 2009-08-06 22:52:00

    @DiggingDeeply
    5是CLR线程池的最大线程,IO线程池不变,就当50吧,反正这里也就用到一个。
    我还是认为有两个不同的线程池是自治的,下一篇文章我会做一些试验的,到时候可能我们讨论起来会有针对性一些。:)

  36. Arthraim
    *.*.*.*
    链接

    Arthraim 2009-08-20 18:57:00

    老赵~ 下的链接指向的还是中 ;D

  37. Arthraim
    *.*.*.*
    链接

    Arthraim 2009-08-20 18:58:00

    原来还没有下啊……

  38. 老赵
    admin
    链接

    老赵 2009-08-20 19:02:00

    @Arthraim
    有啊,已经改好了,呵呵。

  39. 老赵
    admin
    链接

    老赵 2009-08-20 19:06:00

    @Arthraim
    靠,晕了,的确还没有……

  40. Arthraim
    *.*.*.*
    链接

    Arthraim 2009-08-20 19:06:00

    @Jeffrey Zhao
    唉?你链接的是委托,线程池的下呢?

  41. Arthraim
    *.*.*.*
    链接

    Arthraim 2009-08-20 19:08:00

    @Jeffrey Zhao
    呵呵~ 这个可以有~

  42. superbryant
    125.91.25.*
    链接

    superbryant 2010-10-12 09:17:53

    看了老赵的文章之后,越觉得基础的重要性,好好学习,学习老赵,老赵真是一个好程序员!

  43. xiaozou
    1.202.91.*
    链接

    xiaozou 2021-09-16 09:46:29

    现在看到你10年前的文章,还是受益良多,太感谢了!

发表回复

登录 / 登录并记住我 ,登陆后便可删除或修改已发表的评论 (请注意保留评论内容)

昵称:(必填)

邮箱:(必填,仅用于Gavatar

主页:(可选)

评论内容(大于5个字符):

  1. Your Name yyyy-MM-dd HH:mm:ss

使用Live Messenger联系我