多线程
进程和线程的概念
当我们打开一个应用程序后,操作系统就会为该应用程序分配一个进程ID,例如打开Word时,你将在任务管理器虚的进程选项卡中看到WINWORD.EXE进程
进程可以理解为一块包含了某些资源的内存区域,操作系统通过进程这一方式把它的工作划分为不同的单元。一个应用程序可以对应多个进程,例如在打开Chrome浏览器时,任务管理器的应用程序选项卡中只有一个Chrome应用程序,而在进程选项卡中却又多个chrome.exe进程
图
线程是进程中独立执行单元,对于操作系统而言,它通过调度线程来使用应用程序工作。一个进程中至少包含一个线程,我们把该线程称为主线程。线程和进程之间的关系可以理解为:线程是进程的执行单元,操作系统通过调度线程来使应用程序工作;而进程则是线程的容器,它由操作系统创建,又在具体的执行过程中创建了线程。
线程的调度
生活中,要想在吃饭的时候看电视,你需要来回切换这两个动作,它们时由你来进行调度的。在计算机里,线程就相当于你的动作,操作系统就相当于你,操作系统需要调度线程使它们轮流工作。
在操作系统课程中,老师会介绍说“Windows是抢占式多线程操作系统”。之所以说它是抢占式的,是因为线程可以在任意时间里被抢占,来调度另一个线程。操作系统为每个线程分配了0~31中的某一级优先级,而且会把优先级高的线程优先分配给CPU去执行。
Windows 支持七个相对线程优先级:IDle、lowest、BelowNormal、Normal、AboveNormal、hightest 和 Time-Critical。其中,Normal 是默认的线程优先级。程序可以通过设置 Thread 的 Priority 属性来改变线程的优先级,该属性的类型为 ThreadPriority 枚举类型,其成员包括lowest、BelowNormal、Normal、AboveNormal、hightest
成员名称 | 描述 |
---|---|
Lowest | 可以将Thread 安排在其他任何优先级的线程之后 |
BelowNormal | 可以将Thread安排在具有Normal优先级的线程之后,在具有Lowest优先级的线程之前 |
Normal | 可以将Thread安排在具有AboveNormal优先级的线程之后,在具有BelowNormal优先级的线程之前。默认情况下,线程具有Normal优先级 |
AboveNormal | 可以将Thread安排在具有Highst优先级的线程之后,在具有Normal优先级的线程之前 |
Highest | 可以将Thread安排在具有任何其他优先级的线程之前 |
线程也分前后台
线程由前台线程和后台线程之分。在一个进程中,当所有前台线程停止运行后,CLR 会强制结束所有仍在运行的后台线程,这些后台线程被直接种植,却不会抛出任何异常。主线程将一直是前台线程。我们可以使用Thread类来创建前台线程
下面代码演示了前台线程和后台线程之间的区别:
代码语言:javascript复制using System;
using System.Threading;
namespace Demo
{
class Program
{
static void Main(string[] args)
{
Thread backThread = new Thread(Worker);
backThread.IsBackground = true;
backThread.Start();
Console.WriteLine("从主线程退出");
}
public static void Worker()
{
Thread.Sleep(1000);
Console.WriteLine("从后台线程退出");
}
}
}
以上代码首先通过Thread
类创建一个线程对象,然后通过设置IsBackground
属性来指明该线程为后台线程。如果不设置IsBackground
属性,则Thread
类所创建的线程将默认为前台线程。
接着,程序会调用Start
函数来启动该线程,此时后台线程会执行Worker
函数的代码。在Worker
函数中,为了体现出前台线程与后台线程的区别,这里调用了Sleep
使该后台线程睡眠1秒,然后再执行。
从前面的分析可以看出,该控制台程序有两个线程,一个是运行Main函数的主线程,另一个是运行Worker函数的后台线程。由于前台线程执行完毕后CLR会无条件地终止后台线程地运行,所以在前面地代码中,若启动了后台进程,则主线程将会继续执行。
主线程运行完Console.WriteLine("从主线程退出")
语句后就会退出。此时CLR发现主线程运行结束后,则会种植后台线程,然后使整个应用程序结束运行。所以Worker
函数中地Console.WriteLine("从后台线程退出")
语句将不会执行
如果我们想要代码执行,有3种办法:
1、将所创建的线程设置为非后台线程
2、将主线程在后台线程执行完再执行(Thread.Sleep(1000))
3、在主函数中调用Join函数的方法,确保主线程会在后台线程执行结束后才开始运行
代码语言:javascript复制using System;
using System.Threading;
namespace Demo
{
class Program
{
static void Main(string[] args)
{
Thread backThread = new Thread(Worker);
backThread.IsBackground = true;
backThread.Start();
backThread.Join();
Console.WriteLine("从主线程退出");
}
public static void Worker()
{
Thread.Sleep(1000);
Console.WriteLine("从后台线程退出");
}
}
}
以上代码在调用backThread.Join
来确保主线程会在后台线程结束后再运行。这种方式虽然涉及线程同步的概念:再某些情况下,需要两个线程同步运行,即一个线程必须等待另一个线程结束后才能运行。
在前面的代码中,我们使用了Thread(ThreadStart)
构造函数来创建线程对象。除此之外,Thread类还提供了另外3个构造函数,它们分别为Thread(ParameterizedThreadStart start)
、Thread(ParameterizedThreadStart start, int maxStackSize)
和Thread(ThreadStart start, int maxStackSize)
。其中ParameterizedThreadStart
和ThreadStart
都是委托类型
线程的容器——线程池
前面我们都是通过Thread
类来手动创建进程的,然而线程的创建和销毁都会耗费大量时间,这样的手动操作将造成性能损失。因此,为了避免因通过Thread
手动创建线程而造成的损失,.Net
引入了线程池机制
线程池
线程池是指用来存放应用程序中要使用的线程集合,你可以将它理解为一个存放线程的地方,这种集中存放的方式有利于对线程进行管理。
CLR初始化时,线程池中没有线程的。在内部,线程池维护了一个操作请求队列,当应用程序想要执行一个异步操作时,你需要调用QueueUserWorkItem
方法来将对应的任务添加到线程池的请求队列中。线程池实现的代码会从队列中提取任务,并将其委派给线程池中的线程去执行。
如果线程池中没有空闲的线程,线程池就会创建一个新线程去执行提取的任务。而当线程池线程完成了某个任务时,线程也不会被销毁,而是返回线程池中,等待响应另一个请求。由于线程不会被销毁,所以就避免了由此产生的性能损失
这里需要明确一点:由线程池所创建的线程是后台线程,且它的优先级默认为Normal
通过线程池来实现多线程
要使用线程池中的线程,需要调用静态方法ThreadPool.QueueWorkItem
,以指定线程要调用的方法,该静态方法有两个重载版本:
public static bool QueueUserWorkItem(WaitCallback callBack);
public static bool QueueUserWorkItem(WaitCallback callBack, Object state);
这两个方法用于向线程池队列添加一个工作项(work item)以及一个可选的状态数据。然后,这两个方法就会立即返回。工作项是指一个由callback参数标志的委托对象,被委托对象包装的回调方法将由线程池来执行。传入的回调方法必须匹配System.Threading.WaitCallback
委托类型,该委托定义为:
public delegate void waitCallbak(object state);
下面通过实例来延时如何使用线程池来实现多线程编程,具体的演示代码如下:
代码语言:javascript复制using System;
using System.Threading;
namespace Demo
{
class Program
{
static void Main(string[] args)
{
Console.WriteLine("主线程ID = {0}", Thread.CurrentThread.ManagedThreadId);
ThreadPool.QueueUserWorkItem(CallBackWorkItem);
ThreadPool.QueueUserWorkItem(CallBackWorkItem, "work");
Console.WriteLine("从主线程退出");
}
public static void CallBackWorkItem(object state)
{
Console.WriteLine("线程池线程开始执行");
if (state != null)
{
Console.WriteLine("线程池线程ID = {0} 传入的参数为 {1}", Thread.CurrentThread.ManagedThreadId, state.ToString());
}
else
{
Console.WriteLine("线程池线程ID = {0}", Thread.CurrentThread.ManagedThreadId);
}
}
}
}
协作式取消线程池线程
.Net Framework 提供了取消操作的模式,这个模式是协作式的。为了取消一个操作,我们必须首先创建一个System.Threading.CancellationTokenStateSource
对象
下面的代码演示了协作式取消的使用方法,主要实现了用户在控制台下敲下回车键后就停止计数的功能:
代码语言:javascript复制using System;
using System.Threading;
namespace Demo
{
class Program
{
static void Main(string[] args)
{
Console.WriteLine("主线程运行");
CancellationTokenSource cts = new CancellationTokenSource();
ThreadPool.QueueUserWorkItem(callBack, cts.Token);
Console.WriteLine("按下回车键取消");
Console.Read();
cts.Cancel();
Console.ReadKey();
}
private static void callBack(object state)
{
CancellationToken cancellationToken = (CancellationToken)state;
Console.WriteLine("开始计数");
Count(cancellationToken, 100);
}
private static void Count(CancellationToken token, int countno)
{
for (int i = 0; i < countno; i )
{
if (token.IsCancellationRequested)
{
Console.WriteLine("计数取消");
return;
}
Console.WriteLine("计数为:" i);
Thread.Sleep(300);
}
Console.WriteLine("计数完成");
}
}
}
线程同步
线程同步技术是指在多线程程序中,为了保证后者线程,只有等待前者线程完成之后才能继续执行。这就好比在生活中排队买票,在前面的人没买到票之前,后面的人必须等待
多线程程序中存在的隐患
多线程应用程序可以提高程序的性能,并提供更好的用户体验。然而当我们创建了多个线程后,它们就有可能同时去访问某一个共享资源,这将损坏资源中保存的数据。在这种情况下,我们需要使用线程同步技术,确保某一时刻只有一个线程在操作共享资源
举例来说,火车售票系统程序员云熙多人同时购票,因此该系统肯定采用了多线程技术。但由于系统中有多个线程在对统一资源进行操作,我们必须确保只有在其他线程执行结束后,新的线程才开始执行。这样可以避免多位顾客买到同一张火车票。此时需要使用的就是线程同步技术
为了更好地说明线程同步的必要性,下面给出模拟火车票购票系统的代码:
代码语言:javascript复制using System;
using System.Threading;
namespace Demo
{
class Program
{
static int tickets = 100;
static void Main(string[] args)
{
Thread thread1 = new Thread(SaleTicket1);
Thread thread2 = new Thread(SaleTicket2);
thread1.Start();
thread2.Start();
thread1.Join();
thread2.Join();
}
public static void SaleTicket1()
{
while (tickets > 0)
{
Console.WriteLine("线程1正在出票:" tickets--);
}
}
public static void SaleTicket2()
{
while (tickets > 0)
{
Console.WriteLine("线程2正在出票:" tickets--);
}
}
}
}
主线程创建了两个出票线程,然后调用了Start函数让两个线程开始运行,即进行买票。我们执行结果可以看出两个线程交替运行,但是出售的火车票好吗并不是连续的,说明以上应用程序的售票过程是不正确的,这也就是多线程程序所存在的问题,因为两个线程访问了同一个全局变量——tickets
为了避免这种情况的发生,我们需要对多个线程进行同步处理,保证在同一时间内只有一个线程访问共享资源,以及保证前面的线程售票完成后,后面的线程才会访问资源
使用监视器对象实现线程同步
监视器对象(Monitor)能确保线程拥有对共享资源的互斥访问权,C# 通过 lock 关键字来提供简化的语法。下面我们使用线程同步技术来修改前面的代码:
代码语言:javascript复制using System;
using System.Threading;
namespace Demo
{
class Program
{
static int tickets = 100;
static object globalObj = new object();
static void Main(string[] args)
{
Thread thread1 = new Thread(SaleTicket1);
Thread thread2 = new Thread(SaleTicket2);
thread1.Start();
thread2.Start();
thread1.Join();
thread2.Join();
}
public static void SaleTicket1()
{
while (tickets > 0)
{
try
{
Monitor.Enter(globalObj);
Thread.Sleep(1);
Console.WriteLine("线程1正在出票:" tickets--);
}
finally
{
Monitor.Exit(globalObj);
}
}
}
public static void SaleTicket2()
{
while (tickets > 0)
{
try
{
Monitor.Enter(globalObj);
Thread.Sleep(1);
Console.WriteLine("线程2正在出票:" tickets--);
}
finally
{
Monitor.Exit(globalObj);
}
}
}
}
}
注意,使用 Monitor 锁定的对象需要为引用类型,而不能为值类型。因为在将值类型变量传递给 Enter 时,它将被先装箱为一个单独的对象,之后再传递给 Enter 方法;而在将变量传递给 Exit 方法时,也会创建一个单独的引用对象。此时,传递给 Enter 方法的对象和传递给 Exit 方法的对象不同,Monitor 会引发 SynchronizationLockException 异常
线程同步技术存在的问题
在设计应用程序时,应该尽量避免使用线程同步,因为它会引起一些问题
- 它的使用比较繁琐。我们要用额外的代码把多个线程同时访问的数据包围起来,并获取和释放线程的同步锁。如果在一个代码块忘记获取锁,就有可能造成数据损坏
- 使用线程同步会影响程序性能。因为获取和释放锁是需要时间的;并且在决定哪个线程先获取锁的时候,CPU 也必须进行协调。这些额外的工作都会对性能造成影响
- 线程同步每次只允许一个线程访问资源,这会导致线程阻塞。继而,系统会创建更多的线程,CPU 也就要负担更繁重的调度工作。这个过程会对性能造成影响
所以在实际开发过程中,要尽量避免使用线程同步技术,避免使用共享数据