C#基础知识梳理系列十三:线程之美

摘 要

线程,一般认为只有在“复杂场景”中才会使用,有人对它望而生畏,因为它难以管理和控制,而又总有人对它摩拳擦掌,因为它提高了程序的响应速度。这一章我们来讨论Windows对线程的支持、CPU调度、线程开销、线程池、多线程数据同步等,并且再介绍一点关于异步编程的东西。

第一节 Windwos线程及CPU调度

在我们学习操作系统的时候已经知道:Windows 是一个多线程但并非实时的操作系统。

Windows是在一个进程中运行应用程序的每个实例,基于Windows内核可以运行多个进程实例,Windows为每个进程分配了一个独立的虚拟地址空间以保证一个进程无法访问另一个进程的数据,如此一来,不但提高了各进程数据的安全性,也大大提高了系统的容灾能力,因为一个进程出现问题而不会影响到其他进程。

线程是一个轻量级的进程,其实在早期的计算机中就只有一个进程同时它也是一个线程,线程是程序执行流的最小单元。Windows允许一个进程可以同时开启多个线程分别执行不同的任务。目前一个CLR线程对应于一个Windows线程,也就是当你通过.NET语言开启一个线程时,CLR会向Windows申请一个线程。

CPU是计算机的大脑,这也是我们刚开始学习计算机的时候都会学到的。以前CPU是单核心单线程运行的一个电子模块,也就是说,在某一时刻段,它只能执行一个计算任务。后来,随着技术发展,可以在主板上镶嵌多个CPU,这样一来,就可以多个CPU同时工作,大大提高了计算能力。Intel出了一种“超线程芯片”的技术,它以欺骗Windows的方式来“运行两个CPU”组件,其实它是在一个CPU芯片上组装了两个计算架构,从硬件角度,它相当于两个处理器,但是这两个处理器最终在某一时间段还是只能运行一个计算,它是从硬件上自己管理两个“逻辑处理器”的切换,从Windows角度,它不知道CPU有两个工作者“线程”,只是将任务发送给CPU后,CPU在内部虚拟了两个计算器正在并发运行。再后来就是出现了双核CPU乃至三核、四核等多核技术,多核是将多个物理的计算核芯整合到一个CPU组件模块中,这样多个计算核心可以互不影响地并行工作,大大提高了CPU的计算能力。

CPU对线程是未知的,它只知道对交给它的线程执行计算,Windows系统会有选择地选择线程交给CPU来执行,其实我们有时候所说的“CPU调度”,更确切地说法应该是:Windows调度线程交给CPU执行。Windows会为每个线程分配一段时间片,大约30毫秒,当时间片结束时,Windows会暂停当前的线程,再调度另一个线程给CPU,如此一来,系统里的所有线程都可能在很短的时候内得到运行,给人要感觉是所有的线程在并行工作。

 

第二节 线程开销

Windows对多线程的支持大大提高了应用程序响应速度最终给用户提供了良好的用户体验,但是对线程的使用是有性能损伤的。

系统每次创建及初始化一个线程时,都会创建一个线程内核对象的数据结构,此结构描述了当前线程的相关属性和线程上下文,这部分大约占据了几百到数千字节的内存。创建的新线程还包括一个线程环境块TEB,它占用了一页内存(4K或8K)。新线程拥有一个栈user mode stack,此栈大约占据了1M的内存。新线程还有一个栈kernel mode stack,当应用程序代码向操作系统中一个内核模式函数传递一个实参时,Windows会将它们从用户栈复制到内核栈,内核栈大小为12K或24K。以上这些都是在创建一个进程时的空间和时间开销,当然,还有其他的消耗。下面我们来看一下在线程调度时的工作。

通过前面地描述,我们已经知道Windows是调度线程交给CPU去执行,在暂停一个线程且启动另一个线程的过程中会有一个线程上下文切换的过程:

(a)    保存当前线程的状态值到当前线程的上下文结构中;
(b)    从线程集合中取一个新线程;
(c)    将(b)中所选的线程的相关数据加载到CPU寄存器中准备执行。

一个线程得到的时候片大概是30毫秒,线程也可能用不了30毫秒就已经提前结束,时间片到期后,Windows会调度另一个线程,接着又会发生上下文切换。

从以上的空间和时间开销中可以看到,每一次线程切换都会带来一定的性能损伤,在(b)步骤中,如果一个线程与前一线程不在同一个进程,则CPU还要查找到另一个进程的地址空间。所以在我们的开发中,尽量保证少开启线程,Windows系统中的线程越少,则线程得到CPU执行的机会也就越大,线程等待执行的时间也就越短。如果加多CPU核芯,加多CPU数量也会提高系统的性能,这就是为什么服务器一般都拥有N多核的多个CPU,32位的Windows一台机器支持最多32个CPU,64位的Windows支持最多64个CPU。

 

第三节 使用线程

C#中与线程有关的主要类都在命名空间System.Threading中,创建一个线程通常是使用System.Threading.Thread类,其构造函数接受一个必不可少的参数就是线程将要执行的方法,该方法既可以带参数,也可以不带参数,如下:

    public class Code_13 : IApp
    {
        public void DoWork()
        {
            //使用无参的方法
            Thread t = new Thread(new ThreadStart(DoThread1));
            t.Start();
            
            
            //使用有参的方法
            for (int i = 0; i < 100; i++)
            {
                Thread t2 = new Thread(new ParameterizedThreadStart(DoThread2));
                t2.IsBackground = true;
                t2.Start(i);
            }
            //t.Abort();
        }
        private void DoThread1()
        {
            Console.WriteLine("DoThread1");
        }
        private void DoThread2(object msg)
        {
            Console.WriteLine("DoThread2:" + msg);
        }
}

程序迅速打印出了0—99的数字,仔细一点还能从任务管理器中看到这个进程的线程先迅速上升到100多,几秒钟后又下降到10几个,这是因为线程执行完后会自动退出。

初始化完线程对象,必须调用Start()方法启动线程。线程执行完成后会自动退出,如果想提前终止一个线程,可以调用t.Abort()方法,但在调用此方法的线程上会抛出ThreadAbortException异常。

通常对于一个GUI应用程序来说,还有前台线程和后台线程,如果要在UI线程上启动一个后台线程,可以设置线程的属性:IsBackground为true即可,一个线程可以前后台状态可以随时转。一般我们是将有大量进行非人工参与的计算线程设置为后台线程,以防止它在前台线程阻塞时冻结窗口。

 

第四节 异步编程

一个线程由于耗时计算而假死是我们最不愿意看到的现象,因为阻塞,程序不能即时运行阻塞处以下的代码,如果出现在UI上,还会导致UI被冻结,无法操作。那有没有办法来解决这一问题呢?当然有,那就是“异步模型”!它可以提供可响应高健壮性的应用。

异步编程模型(APM) 就是一个线程给耗时请求操作一个回调方法,发出操作请求后,立即返回,不用等待耗时操作,立即执行下面的代码,当耗时操作完成后,CLR的一个线程池线程来执行回调方法,这确是一种美好的体验!也不用被客户骂我们的程序“死了”。CLR提供了很多类似BeginXXX和EndXXX的方法供异常编程模型使用,如FileStream的BeginRead和EndRead方法、HttpWebRequest的BeginGetResponse方法和EndGetResponse方法等。下面是HttpWebRequest的使用示例:

        private void TestHttpRequest()
        {
            HttpWebRequest req = WebRequest.Create("http://www.cnblogs.com/solan") as HttpWebRequest;
            req.BeginGetResponse(new AsyncCallback(ResponseCallback), req);
        }
        private void ResponseCallback(IAsyncResult ia)
        {
            HttpWebRequest req = ia.AsyncState as HttpWebRequest;
            HttpWebResponse res = req.EndGetResponse(ia) as HttpWebResponse;
            Console.WriteLine(res.ContentLength);
        }

通常只要调用了BeginXXX方法,就应该在合适的时候调用对应的EndXXX方法,否则可能会发生内存泄露,因为从初始化异步操作后到调用EndXXX方法前CLR将一直保持着与此步常操作对象相关的资源,只有调用了EndXXX方法,这些资源才得以释放。基于Begin/End的异步操作模型是无法取消的,因为一旦发出异步操作请求,那请求对象就像脱缰的野马不受控制,只能等它干完它干的事才会回来,当然可以在End取到结果后丢弃它来欺骗我们自己。

在有些开发环境下必须使用异步操作,比如silverlight访问服务必须以异步的形式访问,这就给开发带来了麻烦,如果一个操作会多次调用服务且这些服务有先后顺序要求,则更麻烦。幸好在.NET Framework4.5里已经对异步特性进行了增强,如async、await,关于这部分特性,请参考相关资料。

 

第五节 线程池

开启一个线程是如此简单,但是在前面我们已经说过,线程的开销是相当大的,我们应该尽量避免开启新线程,如果我们的项目要求必须使用多线程来达到性能提升的目的呢?使用线程池!

线程池是CLR管理的一个线程集合,每个CLR都有一个自己的线程池,CLR初始化时线程池是空的,可能通过一个方法将异步操作注入线程池,CLR会调一个线程池线程(如果有,否则创建线程)来执行线程池里的方法,当方法执行完后,线程回到线程池而不销毁,等待下一次被使用,这里跟我们在做数据库开发时的连接池相似。线程池的线程经过一次创建,被重复使用,所以从一定程度上提高了程序的性能。如果向线程池中注入的任务数大于线程池的可用线程数,则CLR会创建更多的线程池线程来接收任务,当线程池中有大量闲置的线程时,线程池内的线程会自动结束自己的生命来释放资源。

线程池使用System.Threading.ThreadPool类,向线程池中添加一个异步操作非常简单,有两个静态方法:

        public static bool QueueUserWorkItem(WaitCallback callBack);
        public static bool QueueUserWorkItem(WaitCallback callBack, object state);

该静态方法必须接收一个将要执行的方法,也可以向该方法传递待执行方法所用的数据。如下代码是向线程池添加新任务:

            ThreadPool.QueueUserWorkItem(new WaitCallback(DoThread2));
            ThreadPool.QueueUserWorkItem(new WaitCallback(DoThread2), "QueueUserWorkItem");

线程池如果有闲置线程,会拉出闲置线程让它去执行DoThread2这个方法,如果没有,则创建新的线程。

既然是池,它就有一个容量,当然可以设置线程池的最大活跃线程,调用方法ThreadPool.SetMaxThreads设置即可。假如设置了线程池最大线程数是100,且当前这100个线程都在忙,如果此时再向线程池注册任务,则会排队,只有这100个线程中有闲置的时候才会执行新注册的任务。目前CLR默认线程池可以拥有1000个线程,建议不要修改这个值,让CLR自动管理吧,毕竟微软已经为线程池做了大量优化。

对于注册到线程池里的任务,至于它什么时候完成,我们是无法得知,它适合一些“发出请求,不再处理结果”的情景中,如果我既想用线程池,又想得到执行结果怎么办呢?使用Task!Task允许一个任务启动后,发出等待,等到任务完成后,我们可以拿到任务的处理结果了。关于任务Task的使用,可以参考MSDN文档。

有了线程池和异步模型,在构建高并发高可用性的系统就非常方便了,尤其是在开发服务程序时,当然这只是基础,至于如何应用,还是要我在日常开发中总结经验。我们还需要继续努力!

 

第七节 数据同步

如果一个应用程序只有一个线程,那它能非常安全完好地运行,如果多于一个线程,则有可能多个线程同时访问一个资源。比如在我们平时开发中对登录用户状态数据的管理,为了使这个状态数据共用,通常将其设为静态公共的变量,可能有多个线程都去访问这个变量,这时就可能会出现状态被“非正常”更改。这就要求每个线程在访问这个公共变量前必须加锁以保证某个时间段只有一个线程对其访问。

我们常用的锁就是lock语句,加锁只能对引用类型的对象进行加锁。其他加锁也可以使用Monitor、Mutex、EventWaitHandle等,可以参考相关资料。这里我们说一下开发中很常用的双检锁技术。

双检锁(Double Check Locking),开发人员为了保证在一个应用程序的生命周期中某一类型只有一个实例,并且只有对其进行请求时,才构造该对象,如果一直没对其请求,则其永远不会被构造。这样既保证了它的唯一性,也保证了它的延时构造。这也被称为单件模式。之所以要进行二次检查,就是为了防止多线程访问的情况下出现对象被构造多次的可能。如下代码是用户公共信息的单件实现:

    public class UserInfo
    {
        static object obj = new object();
        static UserInfo _singleton = null;
        public static UserInfo Singleton
        {
            get
            {
                //第一次检测
                if (_singleton == null)
                {
                    //注意:在锁定前,这里可能会有多个线程执行到此
                    //对象还没有构造,这里线程获得一个独占锁
                    lock (obj)
                    {
                        //加锁后再进行第二次检测,以防止在第一次检测后——加锁前这一时间段构造_singleton对象。
                        if (_singleton == null)
                        {
                            _singleton = new UserInfo();
                        }
                    }
                }
                return _singleton;
            }
        }

这就能保证_singleton全局的唯一性。但是以上的代码是在_singleton对象可能在程序中被用到也可能不被用到,且构造它要占用很多资源时,是一个很好的方法,其实有时如果构造对象只耗费了一点点资源,完全可以像下面这样用,达到同样的效果:

        public class UserInfo
        {
            static UserInfo _singleton = new UserInfo();
            public static UserInfo Singleton
            {
                get { return _singleton; }
            }
        }

这样在第一次访问UserInfo类的时候就构造该类的对象,方便。

 

小 结
posted @ 2012-08-30 08:08  solan3000  阅读(5378)  评论(14编辑  收藏  举报