C# 基础知识系列- 12 任务和多线程 C#综合揭秘——细说多线程(上)

C#综合揭秘——细说多线程(下)

0. 前言

照例一份前言,在介绍任务和多线程之前,先介绍一下异步和同步的概念。我们之间介绍的知识点都是在同步执行,所谓的同步就是一行代码一行代码的执行,就像是我们日常乘坐地铁通过安检通道一样,想象我们每个人都是一行代码,我们依次通过安检仪器的时候就是同步。
那么,什么是异步呢?有一个时间利用率的故事,讲的是在烧水的同时,顺便准备茶叶,清洗茶杯等工序可以节省时间。这个故事就是异步的一个典型范例。异步通俗的将就是不暂停也不等待当前耗时的流程执行完成,继续执行后续的流程。
那么这和任务与多线程有什么关系呢?在C#中,基于任务可以很简单的创建一个异步程序或者异步方法;同时任务也是一个简单的多线程模式。不过值得注意的是,C#的异步可以由多线程实现,但多线程更多的是用来实现并行。所谓并行,顾名思义,就是多任务同时执行,这里的任务指的是程序需要完成的事,而不是C#中的任务机制。
这一篇是《C#基础知识系列》的一篇,简单介绍一下如何创建、使用任务和多线程,这部分的内容很多,包括有很多注意事项,将会另开一个系列专门讲解C#的异步和并行编程,名字暂定为《C#异步编程系列》。

1. 线程

了解过计算机的人可能知道程序最小执行单元是线程,最小资源分配单位是进程。进程里必然至少有一个线程,而一个程序也必然至少有一个进程。这里不过多的介绍进程和线程的区别于关系,只需要记着线程是程序最小执行单元,我们在开发中最常用的也是线程。
在很多不太严谨的编程教程中,都会把多线程和并行化作等号。但是这里有一个很微妙的区别,对于单核CPU来说,多进程和多线程一样,都不会产生并行的效果;对于多核CPU而言,多进程必然是并行的,但是多线程则不一定并行。所以C#中,线程更多的用作异步处理上,而不是并行计算上。
在C#程序中,需要引用System.Threading。C#的入门级线程操作只需要知道Thread类、一个带参数的无返回值方法和一个不带参数的无返回值方法,这三个要点就可以了。

1.1 创建一个线程

  1. var thread = new Thread(() =>
  2. {
  3. });

以上示例代码演示了如果创建一个线程。但创建了线程,并不代表线程就会运行。
说到这里就必须说一下线程的状态,一般情况线程分为五个阶段,也就是五种状态:分别是准备、就绪、运行、阻塞、死亡。当然在不同的地方,状态可能会细分为更多的级别,这里只做初步的介绍。状态之间的切换如下:
image.png
线程的状态之间切换顺序有着严格的限制,而且只能从就绪态由CPU切换到运行态,运行态无法从其他状态切换过去,而且这一步的切换开发者不能控制。
现在,我们回到线程的创建方法,先来看看Thread构造方法的声明:

  1. public Thread (System.Threading.ParameterizedThreadStart start);
  2. public Thread (System.Threading.ThreadStart start);

碰到了两个没见过的类型,我们继续看看?

  1. public delegate void ParameterizedThreadStart(object obj);
  2. public delegate void ThreadStart();

到这里,线程的创建为我们揭开了它的谜底。根据之前《C# 基础知识系列- 11 委托和事件》那篇的介绍,我们可以很明确的得到 ThreadStart是一个 无返回值也没有参数的委托,而ParameterizedThreadStart表示有一个object的参数。所以,创建线程的时候,可以直接传一个方法进去。
有的同学可能要问了,为什么创建线程的委托参数那么少?这里涉及到一个并发概念,因为线程访问过多的主线程可能会导致锁,所以最佳的线程实践就是让线程的运行保持一个相对封闭的环境。

当然,C#的线程其实放宽了这部分的限制,这部分将在《C#异步编程系列》中继续探讨。

现在我们回过头来,再看看如何创建一个标准的线程:

  1. class Program{
  2. static void Main(string[] args)
  3. {
  4. var thread1 = new Thread(ThreadTest1);
  5. var thread2 = new Thread(ThreadTest2);
  6. }
  7. /// <summary>
  8. /// 不带参数的线程
  9. /// </summary>
  10. public static void ThreadTest1()
  11. {
  12. // 业务代码
  13. }
  14. /// <summary>
  15. /// 带参数的线程
  16. /// </summary>
  17. /// <param name="obj"></param>
  18. public static void ThreadTest2(object obj)
  19. {
  20. //业务代码
  21. }
  22. }

其中thread1就是一个没有参数的线程,thread2是一个带参数的线程。

注:Main方法是C#程序入口的固定写法,之前所有的示例代码都是在这个方法里执行的,后续这部分会在《C#基础篇之开发工具和项目的基本结构》这一篇中详细介绍,这里先记住这是一个固定写法。

1.2 启动并使用线程

在启动线程之前,我们先介绍一个概念:主线程。主线程指伴随着当前程序启动而启动的线程,以代码来看就是Main方法所在线程。
线程通过调用Thread.Start方法,来将线程标记为就绪态。

注意:线程不能直接进入运行态,该状态只能由CPU决定。

所以上一小节的创建的两个线程可以通过以下方式通知已经准备就续:

  1. thread1.Start();

咦?是不是少了一个?注意力集中的小伙伴会发现,我没有演示thread2的调用方法。thread2与thread1有个不同的地方,thread2的委托参数有一个参数。那么必然Start也有一个对应的带参版本的重载,所以thread2就会有以下两种调用方式:

  1. thread2.Start();

  1. object obj;// 省略来源
  2. thread2.Start(obj);

两种方法有什么区别吗?
有,但是区别不大。第一种调用方式对于方法ThreadTest2而言就是参数为null,第二种就是参数为obj的值。所以第一种调用约等于thread2.Start(null)

1.3 暂停或销毁线程

这一小节的标题是,暂停或销毁线程。当线程运行起来后,如果没有突发情况或者外力干涉会直接运行到结束。这时候,后续程序觉得这个线程执行时间过长,需要暂停或者取消线程的执行,那么就需要了解一下如何暂停或者销毁线程了。

  1. thread1.Suspend();//挂起
  2. thread1.Resume();//继续

中断线程,也就是终止线程:

  1. thread1.Abort();// 已挂起的线程无法中断

强制终止销毁:

  1. thread1.Interrupt();//在执行中的线程无法终止

以上是线程操作的基本概念,这部分并不是为了能让大家精通多线程,这是为了让大家有个初步概念。在C# 中,创建一个线程需要传递一个委托进去,因为委托的性质,并没有限制是否是静态方法,所以这里也可以传一个对象的方法。当然了,我们十分不提倡这样做,因为会导致一些多线程领域里的一些问题。

2. 任务

C#中的任务与线程的区别不是很大,因为C#的任务就是基于线程实现的,而任务比线程更友好,使用也更方便,当然使用也更加复杂。不过对于开发者而言,任务取消了线程的状态切换,只保留了有限的一部分。而且,在C# 更推荐使用任务,任务也是对线程的进一步抽象和改进。

2.1 创建一个任务

如线程相同的一点是,任务的创建也是通过传递一个方法(严格上讲是一个委托)。不同的是,线程的委托没有返回值而且也不接受从线程返回的值,而任务则不同,调用方可以期待任务是有返回值的而且也可以正常使用。
我们先来看看任务是什么,任务的命名空间System.Threading.Tasks,任务的类有以下两种声明:

  1. public class Task : IAsyncResult, IDisposable;
  2. public class Task<TResult> : System.Threading.Tasks.Task;

第一个,没有泛型的Task类表示一个没有返回值的任务;
第二个,泛型Task类表示该任务有一个返回值,返回值的类型为传递进来的泛型参数。
两个任务类的初始化类似于Thread类,不过与之不同的是 泛型Task的参数是Func,都有一个带Object参数的委托。
与线程不同,任务的创建就有很多种方法:
1 通过构造函数创建

  1. var task1 = new Task(() => { });
  2. var task2 = new Task<int>(()=>
  3. {
  4. int i = 0;
  5. return i;
  6. });
  7. task1.Start();
  8. task2.Start();

2 使用任务工厂:

  1. var task1 = Task.Factory.StartNew(() => { });
  2. var task2 = Task.Factory.StartNew(() =>
  3. {
  4. int i = 0;
  5. return i;
  6. });

3 通过Task.Run创建:

  1. var task1 = Task.Run(() => { });
  2. var task2 = Task.Run(() =>
  3. {
  4. int i = 0;
  5. return i;
  6. });

以上三种方式创建的任务是等效的。当然实际上任务的创建并非只有这么几种,但这几种是任务创建的基础,使用频率相当高。

注意:构造函数初始化的任务并不会自动执行,需要手动调用Start方法。而Task.Factory.StartNew和Task.Run创建的任务会自动执行。

2.2 执行任务

与线程不同的是,任务创建完成之后就会自动执行,不需要调用方法。

关于任务的运行有以下需要注意的地方:

  1. 任务的运行不会阻塞主线程;
  2. 主线程结束后,任务一定也会结束;

任务可以IsCompleted属性确定任务是否执行完成,所以可以通过访问任务对象的IsCompleted确认该任务是否执行完成,但有一个问题,这个属性只会表示当前任务是否完成。所以如果需要等待任务完成,则可以通过访问Wait()方法,强制主线程等待任务结束。
如果使用的任务是泛型Task也就是待返回值的任务,可以通过访问Result属性获取任务执行结果。有意思的地方就是,这个属性能获取到结果的时候,也是任务执行完成的时候,所以不需要调用Wait()IsCompleted来判断任务是否完成。

3. 总结

C#中任务基于线程,对其做了更多的抽象和封装,将线程的粒度进一步细分。所以线程在C#中就没有那么重要了,任务逐渐替代了线程在C#程序中的地位。
任务与线程,有共通的地方,也有完全不一样的地方。线程的运行环境相对封闭,所以线程出现错误导致线程中断,不会影响主线程的运行。但任务则不一样了,任务与主线程的关联性更大,一旦任务出现异常导致任务中断,如果没有正确处理,则会影响主线程的运行。

以上是本篇的全部内容,也请大家期待一下《C#异步编程系列》吧。