1、多线程编程必备知识
1.1 进程与线程的概念
当我们打开一个应用程序后,操作系统就会为该应用程序分配一个进程ID,例如打开QQ,你将在任务管理器的进程选项卡看到QQ.exe进程,如下图:
进程可以理解为一块包含了某些资源的内存区域,操作系统通过进程这一方式把它的工作划分为不同的单元。一个应用程序可以对应于多个进程。
线程是进程中的独立执行单元,对于操作系统而言,它通过调度线程来使应用程序工作,一个进程中至少包含一个线程,我们把该线程成为主线程。线程与进程之间的关系可以理解为:线程是进程的执行单元,操作系统通过调度线程来使应用程序工作;而进程则是线程的容器,它由操作系统创建,又在具体的执行过程中创建了线程。
1.2 线程的调度
在操作系统的书中貌似有提过,“Windows是抢占式多线程操作系统”。之所以这么说它是抢占式的,是因为线程可以在任意时间里被抢占,来调度另一个线程。操作系统为每个线程分配了0-31中的某一级优先级,而且会把优先级高的线程优先分配给CPU执行。
Windows支持7个相对线程优先级:Idle、Lowest、BelowNormal、Normal、AboveNormal、Highest和Time-Critical。其中,Normal是默认的线程优先级。程序可以通过设置Thread的Priority属性来改变线程的优先级,该属性的类型为ThreadPriority枚举类型,其成员包括Lowest、BelowNormal、Normal、AboveNormal和Highest。CLR为自己保留了Idle和Time-Critical两个优先级。
1.3 线程也分前后台
线程有前台线程和后台线程之分。在一个进程中,当所有前台线程停止运行后,CLR会强制结束所有仍在运行的后台线程,这些后台线程被直接终止,却不会抛出任何异常。主线程将一直是前台线程。我们可以使用Tread类来创建前台线程。
1 using System; 2 using System.Threading; 3 4 namespace 多线程1 5 { 6 internal class Program 7 { 8 private static void Main(string[] args) 9 { 10 var backThread = new Thread(Worker); 11 backThread.IsBackground = true; 12 backThread.Start(); 13 Console.WriteLine("从主线程退出"); 14 Console.ReadKey(); 15 } 16 17 private static void Worker() 18 { 19 Thread.Sleep(1000); 20 Console.WriteLine("从后台线程退出"); 21 } 22 } 23 }
以上代码先通过Thread类创建了一个线程对象,然后通过设置IsBackground属性来指明该线程为后台线程。如果不设置这个属性,则默认为前台线程。接着调用了Start的方法,此时后台线程会执行Worker函数的代码。所以在这个程序中有两个线程,一个是运行Main函数的主线程,一个是运行Worker线程的后台线程。由于前台线程执行完毕后CLR会无条件地终止后台线程的运行,所以在前面的代码中,若启动了后台线程,则主线程将会继续运行。主线程执行完后,CLR发现主线程结束,会终止后台线程,然后使整个应用程序结束运行,所以Worker函数中的Console语句将不会执行。所以上面代码的结果是不会运行Worker函数中的Console语句的。
可以使用Join函数的方法,确保主线程会在后台线程执行结束后才开始运行。
1 using System; 2 using System.Threading; 3 4 namespace 多线程1 5 { 6 internal class Program 7 { 8 private static void Main(string[] args) 9 { 10 var backThread = new Thread(Worker); 11 backThread.IsBackground = true; 12 backThread.Start(); 13 backThread.Join(); 14 Console.WriteLine("从主线程退出"); 15 Console.ReadKey(); 16 } 17 18 private static void Worker() 19 { 20 Thread.Sleep(1000); 21 Console.WriteLine("从后台线程退出"); 22 } 23 } 24 }
以上代码调用Join函数来确保主线程会在后台线程结束后再运行。
如果你线程执行的方法需要参数,则就需要使用new Thread的重载构造函数Thread(ParameterizedThreadStart).
1 using System; 2 using System.Threading; 3 4 namespace 多线程1 5 { 6 internal class Program 7 { 8 private static void Main(string[] args) 9 { 10 var backThread = new Thread(new ParameterizedThreadStart(Worker)); 11 backThread.IsBackground = true; 12 backThread.Start("Helius"); 13 backThread.Join(); 14 Console.WriteLine("从主线程退出"); 15 Console.ReadKey(); 16 } 17 18 private static void Worker(object data) 19 { 20 Thread.Sleep(1000); 21 Console.WriteLine($"传入的参数为{data.ToString()}"); 22 } 23 } 24 }
执行结果为:
2、线程的容器——线程池
前面我们都是通过Thead类来手动创建线程的,然而线程的创建和销毁会耗费大量时间,这样的手动操作将造成性能损失。因此,为了避免因通过Thread手动创建线程而造成的损失,.NET引入了线程池机制。
2.1 线程池
线程池是指用来存放应用程序中要使用的线程集合,可以将它理解为一个存放线程的地方,这种集中存放的方式有利于对线程进行管理。
CLR初始化时,线程池中是没有线程的。在内部,线程池维护了一个操作请求队列,当应用程序想要执行一个异步操作时,需要调用QueueUserWorkItem方法来将对应的任务添加到线程池的请求队列中。线程池实现的代码会从队列中提取,并将其委派给线程池中的线程去执行。如果线程池没有空闲的线程,则线程池也会创建一个新线程去执行提取的任务。而当线程池线程完成某个任务时,线程不会被销毁,而是返回到线程池中,等待响应另一个请求。由于线程不会被销毁,所以也就避免了性能损失。记住,线程池里的线程都是后台线程,默认级别是Normal。
2.2 通过线程池来实现多线程
要使用线程池的线程,需要调用静态方法ThreadPool.QueueUserWorkItem,以指定线程要调用的方法,该静态方法有两个重载版本:
public static bool QueueUserWorkItem(WaitCallback callBack);
public static bool QueueUserWorkItem(WaitCallback callback,Object state)
这两个方法用于向线程池队列添加一个工作先以及一个可选的状态数据。然后,这两个方法就会立即返回。下面通过实例来演示如何使用线程池来实现多线程编程。
1 using System; 2 using System.Threading; 3 4 namespace 多线程2 5 { 6 class Program 7 { 8 static void Main(string[] args) 9 { 10 Console.WriteLine($"主线程ID={Thread.CurrentThread.ManagedThreadId}"); 11 ThreadPool.QueueUserWorkItem(CallBackWorkItem); 12 ThreadPool.QueueUserWorkItem(CallBackWorkItem,"work"); 13 Thread.Sleep(3000); 14 Console.WriteLine("主线程退出"); 15 Console.ReadKey(); 16 } 17 18 private static void CallBackWorkItem(object state) 19 { 20 Console.WriteLine("线程池线程开始执行"); 21 if (state != null) 22 { 23 Console.WriteLine($"线程池线程ID={Thread.CurrentThread.ManagedThreadId},传入的参数为{state.ToString()}"); 24 } 25 else 26 { 27 Console.WriteLine($"线程池线程ID={Thread.CurrentThread.ManagedThreadId}"); 28 } 29 } 30 } 31 }
结果为:
2.3 协作式取消线程池线程
.NET Framework提供了取消操作的模式,这个模式是协作式的。为了取消一个操作,必须创建一个System.Threading.CancellationTokenSource对象。下面还是使用代码来演示一下:
using System; using System.Threading; namespace 多线程3 { internal class Program { private static void Main(string[] args) { Console.WriteLine("主线程运行"); var cts = new CancellationTokenSource(); ThreadPool.QueueUserWorkItem(Callback, cts.Token); Console.WriteLine("按下回车键来取消操作"); Console.Read(); cts.Cancel(); Console.ReadKey(); } private static void Callback(object state) { var token = (CancellationToken) state; Console.WriteLine("开始计数"); Count(token, 1000); } private static void Count(CancellationToken token, int count) { for (var i = 0; i < count; i++) { if (token.IsCancellationRequested) { Console.WriteLine("计数取消"); return; } Console.WriteLine($"计数为:{i}"); Thread.Sleep(300); } Console.WriteLine("计数完成"); } } }
结果为:
3、线程同步
线程同步计数是指多线程程序中,为了保证后者线程,只有等待前者线程完成之后才能继续执行。这就好比生活中排队买票,在前面的人没买到票之前,后面的人必须等待。
3.1 多线程程序中存在的隐患
多线程可能同时去访问一个共享资源,这将损坏资源中所保存的数据。这种情况下,只能采用线程同步技术。
3.2 使用监视器对象实现线程同步
监视器对象(Monitor)能够确保线程拥有对共享资源的互斥访问权,C#通过lock关键字来提供简化的语法。
1 using System; 2 using System.Collections.Generic; 3 using System.Linq; 4 using System.Text; 5 using System.Threading; 6 using System.Threading.Tasks; 7 8 namespace 线程同步 9 { 10 class Program 11 { 12 private static int tickets = 100; 13 static object globalObj=new object(); 14 static void Main(string[] args) 15 { 16 Thread thread1=new Thread(SaleTicketThread1); 17 Thread thread2=new Thread(SaleTicketThread2); 18 thread1.Start(); 19 thread2.Start(); 20 Console.ReadKey(); 21 } 22 23 private static void SaleTicketThread2() 24 { 25 while (true) 26 { 27 try 28 { 29 Monitor.Enter(globalObj); 30 Thread.Sleep(1); 31 if (tickets > 0) 32 { 33 Console.WriteLine($"线程2出票:{tickets--}"); 34 } 35 else 36 { 37 break; 38 } 39 } 40 catch (Exception) 41 { 42 throw; 43 } 44 finally 45 { 46 Monitor.Exit(globalObj); 47 } 48 } 49 } 50 51 private static void SaleTicketThread1() 52 { 53 while (true) 54 { 55 try 56 { 57 Monitor.Enter(globalObj); 58 Thread.Sleep(1); 59 if (tickets > 0) 60 { 61 Console.WriteLine($"线程1出票:{tickets--}"); 62 } 63 else 64 { 65 break; 66 } 67 } 68 catch (Exception) 69 { 70 throw; 71 } 72 finally 73 { 74 Monitor.Exit(globalObj); 75 } 76 } 77 } 78 } 79 }
在以上代码中,首先额外定义了一个静态全局变量globalObj,并将其作为参数传递给Enter方法。使用了Monitor锁定的对象需要为引用类型,而不能为值类型。因为在将值类型传递给Enter时,它将被先装箱为一个单独的毒香,之后再传递给Enter方法;而在将变量传递给Exit方法时,也会创建一个单独的引用对象。此时,传递给Enter方法的对象和传递给Exit方法的对象不同,Monitor将会引发SynchronizationLockException异常。
3.3 线程同步技术存在的问题
(1)使用比较繁琐。要用额外的代码把多个线程同时访问的数据包围起来,还并不能遗漏。
(2)使用线程同步会影响程序性能。因为获取和释放同步锁是需要时间的;并且决定那个线程先获得锁的时候,CPU也要进行协调。这些额外的工作都会对性能造成影响。
(3)线程同步每次只允许一个线程访问资源,这会导致线程堵塞。继而系统会创建更多的线程,CPU也就要负担更繁重的调度工作。这个过程会对性能造成影响。
下面就由代码来解释一下性能的差距:
1 using System; 2 using System.Collections.Generic; 3 using System.Diagnostics; 4 using System.Linq; 5 using System.Text; 6 using System.Threading; 7 using System.Threading.Tasks; 8 9 namespace 线程同步2 10 { 11 class Program 12 { 13 static void Main(string[] args) 14 { 15 int x = 0; 16 const int iterationNumber = 5000000; 17 Stopwatch stopwatch=Stopwatch.StartNew(); 18 for (int i = 0; i < iterationNumber; i++) 19 { 20 x++; 21 } 22 Console.WriteLine($"不使用锁的情况下花费的时间:{stopwatch.ElapsedMilliseconds}ms"); 23 stopwatch.Restart(); 24 for (int i = 0; i < iterationNumber; i++) 25 { 26 Interlocked.Increment(ref x); 27 } 28 Console.WriteLine($"使用锁的情况下花费的时间:{stopwatch.ElapsedMilliseconds}ms"); 29 Console.ReadKey(); 30 } 31 } 32 }
执行结果: