C#之线程和并发

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

本文讲线程和并发,这块知识点太多太多了,不可能用一篇文章写的面面具到(自己主题就是C#高级知识概要嘛),我所了解的也有限。但对于Web开发,我想本文的知识点应该足够,若是后面有遇到本文没讲的,后面再补充吧。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帮它写日志,同时又继续在本身的其它工做,这种状况就是异步

(经读者反馈,为不“误导”读者(尽管我我的不以为是误导。以前个人定义和解释不全面,没有从操做系统和CPU层次去区分这两个概念。个人文章不喜欢搬教科书,只是想用通俗易读的白话让你们理解),为了知识的专业性和严谨,现已把我理解的对并发和异步的定义删除,感谢园友们的热心讨论)。

 

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

并发控制 - 锁

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.5 才有 Task.Run (() => Console.WriteLine ("Hello from the thread pool")); // 方式2:ThreadPool.QueueUserWorkItem ThreadPool.QueueUserWorkItem (t => Console.WriteLine ("Hello from the thread pool"));

线程池使得线程能够充分有效地被使用,减小了任务启动的延迟。可是不是全部的状况都适合使用线程池中的线程,好比下面要讲的日志案例 - 异步写文件。

这里讲线程池,是为了让你们大体了解何时用线程池中的线程,何时不用。即,耗时长或有阻塞状况的不用线程池中的线程。

建立不走线程池中的线程,能够直接经过new Thread来建立,也能够经过下面的代码来建立:

Task task = Task.Factory.StartNew (() => ...,TaskCreationOptions.LongRunning);// 注意必须带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; // 构造函数,初始化。 private 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类并不完美,并且存在线程安全问题(代码中用红色字体标出),虽然实际环境几率很小。可能上面代码屡次运行都很难看到有异常发生(我屡次运行未发生异常),但同时再添加几个线程可能就会有问题了。

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

相关文章
相关标签/搜索