首页 > 代码库 > [ASP.NET MVC 大牛之路]03 - C#高级知识点概要(2) - 线程和并发

[ASP.NET MVC 大牛之路]03 - C#高级知识点概要(2) - 线程和并发

我也想过跳过C#高级知识点概要直接讲MVC,但经过前思后想,还是觉得有必要讲的。我希望通过自己的经验给大家一些指引,带着大家一起走上ASP.NET MVC大牛之路,少走弯路。同时也希望能和大家一起交流,这样也能发现我自己的不足,对我自己的帮助也是非常大的。

建议大家对C#撑握的不错的时候,可以去看一些开源项目。走技术这条路,就要耐得住寂寞(群里双休日说要让群主找妹子进群的人必须反思),练好内功。不撑握C#高级知识点,别想看懂优秀的开源项目,更别指望吸收其编程思想;你的水平,随时可以被一个实习生代替!切记不能浮躁!

本文讲线程和并发,这块知识点太多太多了,不可能用一篇文章写的面面具到(本身主题就是C#高级知识概要嘛),我所了解的也有限。但对于Web开发,我想本文的知识点应该足够,如果后面有遇到本文没讲的,后面再补充吧。

本文目录:

  • 线程的简单使用
  • 并发和异步的区别
  • 并发控制 - 锁
  • 线程的信号机制
  • 线程池中的线程
  • 案例:支持并发的异步日志组件
  • 结语

线程的简单使用

并发和异步都是基于线程来实现的,所以本文先讲线程的简单使用方法。

使用线程,我们需要引用System.Threading命名空间。创建一个线程最简单的方法就是在 new 一个 Thread,并传递一个ThreadStart委托(无参数)或ParameterizedThreadStart委托(带参数),如下:

class Program {    static void Main(string[] args) {        // 使用无参数委托ThreadStart        Thread t = new Thread(Go);        t.Start();        // 使用带参数委托ParameterizedThreadStart        Thread t2 = new Thread(GoWithParam);        t2.Start("Message from main.");        t2.Join();// 等待线程t2完成。        Console.WriteLine("Thread t2 has ended!");        Console.ReadKey();    }    static void Go() {        Console.WriteLine("Go!");    }    static void GoWithParam(object msg) {        Console.WriteLine("Go With Param! Message: " + msg);        Thread.Sleep(1000);// 模拟耗时操作    }}

运行结果:

技术分享

线程的用法,我们只需要了解这么多。下面我们再来通过一段代码来讲讲并发和异步。

并发和异步的区别

关于并发和异步,我们先来写一段代码,模拟多个线程同时写1000条日志:

class Program {    static void Main(string[] args) {        Thread t1 = new Thread(Working);        t1.Name = "Thread1";        Thread t2 = new Thread(Working);        t2.Name = "Thread2";        Thread t3 = new Thread(Working);        t3.Name = "Thread3";        // 依次启动3个线程。        t1.Start();        t2.Start();        t3.Start();        Console.ReadKey();    }    // 每个线程都同时在工作    static void Working() {        // 模拟1000次写日志操作        for (int i = 0; i < 1000; i++) {            Logger.Write(Thread.CurrentThread.Name + " writes a log: " + i + ", on " + DateTime.Now.ToString() + ".\n");        }// 做一些其它的事件        for (int i = 0; i < 1000; i++) { }    }}

代码很简单,相信大家都能看得懂。Logger 大家可以把它看做是一个写日志的组件,先不关心它的具体实现,只要知道它是一个提供了写日志功能的组件就行。

那么,这段代码跟并发和异步有什么关系呢?

我们先用一张图来描述这段代码:

技术分享

观察上图,3个线程同时调用Logger写日志,对于Logger来说,3个线程同时交给了它任务,这种情况就是并发。对于其中一个线程来说,它在工作过程中,在某个时间请求Logger帮它写日志,同时又继续在自己的其它工作,这种情况就是异步。对于主线程来说,它启动了三个新的线程去做事情,就是异步操作。

根据我个人的理解,简单的说,并发和异步的区别是:

并发是在同一时间处理多件事件;异步是在做一件事情的同时调用一个或多个处理。异步操作都是并发进行的。

再举个现实生活的例子来理解一下:一个团队开发一个App,给团队的各个成员都分配好任务后,所有成员都开始工作了,时间上,这些成员的工作是并发进行的。成员A要实现一个功能模块,开发过程中,他请求另一个成员B帮他做一个插件,需求告诉B以后(发送请求),A继续做自己的事件,B做完插件后告诉A并把这个插件交给A(回调),A再把这个插件集成到他的功能模块中,对于该功能模块的完成,A是异步请求B帮了个忙。

不知这样解释是否十分清楚,欢迎大家补充和纠正。

大部分应用程序都多多少少会用到并发和异步。由于.NET Framework后面版本对并发和异步编程的不断优化和简单化,并发和异步也越来越多地被开发者所使用。

常见的并发应用场景,有如上面的多个线程写日志、一边读数据一边显示读取进度等。常见的异步应用场景,有如启动一个线程去做一件事、典型的Ajax等。还有比如大部分网站注册完后立即显示一个页面告诉用户已发送激活邮件到您的邮箱的通知,而不是等邮件发送完了再显示。这种情况下使用异步,用户体验更好,Web性能也更佳。瞬聘网也是这么做的,没有注册的朋友可以在注册瞬聘网的时候注意观察这个细节。

其实还有个概念叫“并行”,和“并发”基本上是一个意思,两者在CPU层次有明显的区分。但在宏观上,对于上层Web开发者,我们把“并行”和“并发”当作一会事就好了,不需要去区分。

后续博文会专门讲解异步,本文是后面讲异步必要的铺垫。

接下来,我们继续讲几个很有用的有关线程和并发的知识 - 锁、信号机制和线程池。

并发控制 - 锁

CLR 会为每个线程分配自己的内存堆空间,以使他们的本地变量保持分离互不干扰。

线程之间也可以共享通用的数据,比如同一对象的某个属性或全局静态变量。但线程间共享数据是存在安全问题的。举个例子,下面的主线程和新线程共享了变量done,done用来标识某件事已经做过了(告诉其它线程不要再重复做了):

class Program {    static bool done;    static void Main(string[] args) {        new Thread(Go).Start(); // 在新的线程上调用Go        Go(); // 在主线程上调用Go        Console.ReadKey();    }    static void Go() {        if (!done) {            Thread.Sleep(500); // 模拟耗时操作            Console.WriteLine("Done");             done = true;        }    }}

输出结果:

技术分享

输出了两个“Done”,事件被做了两次。由于没有控制好并发,这就出现了线程的安全问题,无法保证数据的状态。

要解决这个问题,就需要用到锁(Lock,也叫排它锁或互斥锁)。使用lock语句,可以保证共享数据只能同时被一个线程访问。lock的数据对象要求是不能null的引用类型的对象,所以lock的对象需保证不能为空。为此需要创建一个不为空的对象来使用锁,修改一下上面的代码如下:

class Program {    static bool done;    static object locker = new object(); // !!    static void Main(string[] args) {        new Thread(Go).Start(); // 在新的线程上调用Go        Go(); // 在主线程上调用Go        Console.ReadKey();    }    static void Go() {        lock (locker) {            if (!done) {                Thread.Sleep(500); // Doing something.                Console.WriteLine("Done");                done = true;            }        }    }}

再看结果:

技术分享

使用锁,我们解决了问题。但使用锁也会有另外一个线程安全问题,那就是“死锁”,死锁的概率很小,但也要避免。保证“上锁”这个操作在一个线程上执行是避免死锁的方法之一,这种方法在下文案例中会用到。

这里我们就不去深入研究“死锁”了,感兴趣的朋友可以去查询相关资料。

线程的信号机制

有时候你需要一个线程在接收到某个信号时,才开始执行,否则处于等待状态,这是一种基于信号的事件机制。.NET框架提供一个ManualResetEvent类来处理这类事件,它的 WaiOne 实例方法可使当前线程一直处于等待状态,直到接收到某个信号。它的Set方法用于打开发送信号。下面是一个信号机制的使用示例:

static void Main(string[] args) {    var signal = new ManualResetEvent(false);    new Thread(() => {        Console.WriteLine("Waiting for signal...");        signal.WaitOne();        signal.Dispose();        Console.WriteLine("Got signal!");    }).Start();    Thread.Sleep(2000);    signal.Set();// 打开“信号”    Console.ReadKey();}

运行结果:

技术分享

当执行Set方法后,信号保持打开状态,可通过Reset方法将其关闭,若不再需要,通过Dispose将其释放。如果预期的等待时间很短,可以用ManualResetEventSlim代替ManualResetEvent,前者在等待时间较短时性能更好。信号机制非常有用,后面的日志案例会用到它。

线程池中的线程

线程池中的线程是由CLR来管理的。在下面两种条件下,线程池能起到最好的效用:

  • 任务运行的时候比较短(<250ms),这样CLR可以充分调配现有的空闲线程来处理该任务;
  • 大量时间处于等待(或阻塞)的任务不去支配线程池的线程。

要使用线程中的线程,主要有下面两种方式:

// 方式1:Task.Run,.NET Framework 4.0 才有Task.Run (() => Console.WriteLine ("Hello from the thread pool"));// 方式2:ThreadPool.QueueUserWorkItemThreadPool.QueueUserWorkItem (t => Console.WriteLine ("Hello from the thread pool"));

线程池使得线程可以充分有效地被使用,减少了任务启动的延迟。但是不是所有的情况都适合使用线程池中的线程,比如下面要讲的日志案例 - 异步写文件。

这里讲线程池,是为了让大家大致了解什么时候用线程池中的线程,什么时候不用。即,耗时长或有阻塞情况的不用线程池中的线程。

创建不走线程池中的线程,可以直接通过new Thread来创建,也可以通过下面的代码来创建:

Task task = Task.Factory.StartNew (() => ...,TaskCreationOptions.LongRunning);

这里用到了Task,大家不用关心它,后续博文会详细讲。

关于线程的知识很多,这里不再深入了,因为这些已经足够让我们应付Web开发了。

案例:支持并发的异步日志组件

上文的“并发和异步的区别”的代码中我们用到了一个Logger类,现在我们就来做一个这样的Logger。

基于上面的知识,我们可以实现应用程序的并发写日志日志功能。在应用程序中,写日志是常见的功能,简单分析一下该功能的需求:

  1. 在后台异步执行,和其它线程互不影响。
    根据上文线程池的两个最优使用条件,由写日志线程会长时间处于阻塞(或运行等待)状态,所以它不适合使用线程池。即不能使用Task.Run,而最好使用new Thread。

  2. 支持并发,即多个任务(分布在不同线程上)可同时调用写日志功能,但需保证线程安全。
    支持并发,必然要用到锁,但要完全保证线程安全,那就要想办法避免“死锁”。只要我们把“上锁”的操作始终由同一个线程来做即可避免“死锁”问题,但这样的话,并发请求的任务只能放在队列中由该线程依次执行(因为是后台执行,无需即时响应用户,所以可以这么做)。

  3. 单个实例,单个线程。
    任何地方调用写日志功能都调用的是同一个Logger实例(显然不能每次写日志都新建一个实例),即需使用单例模式。不管有多少任务调用写日志功能,都必须始终使用同一个线程来处理这些写日志操作,以保证不占用过多的线程资源和避免新建线程带来的延迟。

运用上面的知识,我们来写一个这样的类。简单理一下思路:

  1. 需要一个用来存放写日志任务的队列。
  2. 需要有一个信号机制来标识是否有新的任务要执行。
  3. 当有新的写日志任务时,将该任务加入到队列中,并发出信号。
  4. 用一个方法来处理队列中的任务,当接收新任务信号时,就依次调用队列中的任务。

开发一个功能前需要有个简单的思路,保证心里面有底。具体开发的时候会发现问题,然后再去补充扩展和完善等。刚开始很难想得太周全,先有个简单的思路,然后代码写起来!

下面是这样一个Logger类初步实现:

public class Logger {    // 用于存放写日志任务的队列    private Queue<Action> _queue;    // 用于写日志的线程    private Thread _loggingThread;    // 用于通知是否有新日志要写的“信号器”    private ManualResetEvent _hasNew;    // 构造函数,初始化。    public Logger() {        _queue = new Queue<Action>();        _hasNew = new ManualResetEvent(false);        _loggingThread = new Thread(Process);        _loggingThread.IsBackground = true;        _loggingThread.Start();    }    // 使用单例模式,保持一个Logger对象    private static readonly Logger _logger = new Logger();    private static Logger GetInstance() {        /* 不安全代码        lock (locker) {            if (_logger == null) {                _logger = new Logger();            }        }*/        return _logger;    }    // 处理队列中的任务    private void Process() {        while (true) {            // 等待接收信号,阻塞线程。            _hasNew.WaitOne();            // 接收到信号后,重置“信号器”,信号关闭。            _hasNew.Reset();             // 由于队列中的任务可能在极速地增加,这里等待是为了一次能处理更多的任务,减少对队列的频繁“进出”操作。            Thread.Sleep(100);            // 开始执行队列中的任务。            // 由于执行过程中还可能会有新的任务,所以不能直接对原来的 _queue 进行操作,            // 先将_queue中的任务复制一份后将其清空,然后对这份拷贝进行操作。            Queue<Action> queueCopy;            lock (_queue) {                queueCopy = new Queue<Action>(_queue);                _queue.Clear();            }            foreach (var action in queueCopy) {                action();            }        }    }    private void WriteLog(string content) {        lock (_queue) { // todo: 这里存在线程安全问题,可能会发生阻塞。            // 将任务加到队列            _queue.Enqueue(() => File.AppendAllText("log.txt", content));        }        // 打开“信号”        _hasNew.Set();    }    // 公开一个Write方法供外部调用    public static void Write(string content) {        // WriteLog 方法只是向队列中添加任务,执行时间极短,所以使用Task.Run。        Task.Run(() => GetInstance().WriteLog(content));    }}

类写好了,用上文“并发和异步的区别”中的代码测试一下这个Logger类,在我的电脑上运行的一次结果:

技术分享

 共3000条日志,结果没有问题。

上面的Logger类注释写得很详细,我就不再解析了。

通过这个示例,目的是让大家掌握线程和并发在开发中的基本应用和要注意的问题。

遗憾的是这个Logger类并不完美,而且存在线程安全问题(代码中用红色字体标出),虽然实际环境概率很小。可能上面代码多次运行都很难看到有异常发生(我多次运行未发生异常),但同时再添加几个线程可能就会有问题了。

那么,如何解决这个线程安全问题呢?

结语

本文结束了,但留给了大家一个问题 - 如何解决上面的Logger类的线程安全问题。希望有兴趣的朋友们思考后都能参与讨论。

除此,大家还可一起讨论如何完善它,如何更好的封装它,使其更安全、更健壮、性能更优等等。

Logger 类 Fork 地址: https://github.com/LiamWang/SimpleLogger

欢迎留言或加QQ群239152943交流。

 

 

[ASP.NET MVC 大牛之路]03 - C#高级知识点概要(2) - 线程和并发