C# 篇基础知识10——多线程
1.线程的概念
单核CPU的计算机中,一个时刻只能执行一条指令,操作系统以“时间片轮转”的方式实现多个程序“同时”运行。操作系统以进程(Process)的方式运行应用程序,进程不但包括应用程序的指令流,也包括运行程序所需的内存、寄存器等资源。因为交替时间很短(一般只有几十毫秒),人们根本感觉不到如此短暂的停顿,所以在表面上看来就像多个工作同时进行似的。因此进程在宏观上是并发进行的,在微观上是交替进行的。
后来出现了多线程技术(Multi-threading),可以通过在一个进程中创建多个线程(Threading),系统以“时间片轮转”的方式交替执行多个线程,使得可以在一个程序中同时执行多项工作。同一个进程中的所有线程共享进程的资源,所以它们之间的切换就比进程间的切换快的多,因此线程可以看作轻量级进程(Lightweight Process)。现代的操作系统都是多进程(Multi-process)的操作系统,每个进程中运行一个或多个线程,所以大多数时间操作系统中都有多个线程并发运行。操作系统中有专门的调度程序管理线程,它根据事先设计好的算法轮流执行每个线程。线程是操作系统进行CPU 调度的基本单位,线程的调度是由操作系统自动完成的,无须程序员关心。程序员只需编写好线程即可,线程的轮转交由操作系统完成。随着多核心CPU的出现,使得线程能够真正的实现同步执行,多线程技术从此翻开新的篇章。
2.Thread类
一般情况下,每开启一个应用程序,系统就会创建一个与该程序相关的的进程,紧接着进程就会创建一个主线程(Main Thread),然后从主函数中的代码开始执行。可以在一个应用程序中创建任意多个线程,每个线程完成一项任务。C#中,线程由System.Threading 命名空间中的Thread 类实现,声明语句:
Thread workThread = new Thread(entryPoint);
其中entryPoint 代表一个入口方法,线程的具体代码放在入口方法中,系统从入口方法的第一句代码开始执行线程。入口方法的参数和返回值类型由ThreadStart 委托或ParameterizedThreadStart 委托规定。
public delegate void ThresdStart();
public delegate void ParameterizedThreadStart(Object obj);
除了通过委托传递线程的入口方法外,还可以通过匿名方法或Lambda表达式创建线程。
Thread drawGraphThread=new Thread(delegate() { //入口方法中的代码});
Thread drawGraphThread=new Thread(() => { //入口方法中的代码});
匿名方法可以使用外部变量,所以用匿名方法定义的线程可以使用在线程前面定义的变量,这弥补了入口方法没有参数和返回值的问题。
3.线程的优先级
计算机中经常会有多个任务同时运行,其中总有一些看起来更紧急,更需要优先执行。线程的优先级可以通过Thread类Priority属性设置,Priority属性是一个ThreadPriority型枚举,包含5个优先等级:Highest、AboveNormal、Normal BelowNormal、Lowest。应先设置线程优先级,再执行线程,并且,任何一个程序的Main()方法将占用一个主线程。
//改变线程优先级
threadA.Priority = ThreadPriority.AboveNormal;
threadB.Priority = ThreadPriority.BelowNormal;
//启动线程
threadA.Start();
threadB.Start();
4.线程的插入
Thread类的Join()方法能够将两个原本交替执行的线程变为顺序执行。
Using System.Threading;
Static void Main(string [] args)
{ //线程A
Thread threadA=new Thread(delegate()
{ for(int i=0;i<=10000000;i++)
{ if(i%1000000==0)
{Console.Write(‘A’); }
}
});
//线程B
Thread threadB=new Thread(delegate()
{ for (int i=0;i<=50000000;i++)
{ if(i%1000000==0)
{Console.Write(‘B’);}
}
//在这里插入线程A
threadA.Join();
for(int i=0;i<=50000000;i++)
{ if(i%10000000==0)
{ Console.Write(‘b’);}
}
});
//启动线程
threadA.Start();
threadB.Start();
}
一开始两个线程交替进行,当线程 B 执行到语句“threadA.Join()”时,线程A 中剩余的代码插入到线程B 之中,从此刻起,停止执行线程B,专门执行线程A,直到执行完线程A 中的所有语句,才去执行线程B 中剩余的语句。从线程 B 的角度看,在线程B 中调用threadA.Join(),相当于在在线程B 中调用了一个方法,只有线程threadA 执行完毕之后该方法才会返回,Join()方法还可以接受一个表示毫秒数的参数,当达到指定时间后,即使线程A 还没运行完毕, Join()方法也返回,这时线程A 和线程B 再次处于交替运行中。
5.线程的状态
线程的状态由Thread类的ThreadState属性表示:
当一个线程被创建后,它就处于Unstarted 状态,直到调用了Start()方法为止。但处于Running 状态的线程也不是一定正在被CPU 执行,可能该线程的时间片刚刚用完,CPU 正在处理其他线程,过一段时间后才会处理它。
有三种方法使线程由 Running 状态变为WaitSleepJoin 状态。第一种情况是为了保持线程间的同步而使之处于等待状态,这一点将在下一节讲到。第二种情况是线程调用了Sleep()方法而处于睡眠状态,当达到指定的睡眠时间以后,线程将会回到Running 状态。第三种情况是调用了Join()方法,比如在线程A 的代码中调用了线程B.Join()方法,线程A 将处于WaitSleepJoin 状态,直到线程B 结束,开始继续执行线程A 时为止。如果当线程处于 Running 状态时调用线程的Suspend()方法,线程将由Running 状态变为SuspendedRequested 状态(请求挂起状态),线程一般会再继续执行几个指令,当确保线程在安全的状态下时,挂起线程,这时线程变为Suspended 状态(挂起状态)。调用线程的Resume()方法,可使线程回到Running 状态。
线程的状态是由操作系统的调度程序决定的,所以除了在一些调试方案中,一般不使用线程的状态。但线程的Background 状态除外,可以通过Thread 类的IsBackground 属性把线程设置为Background 状态。其实后台线程跟前台线程只有一个区别,那就是后台线程不妨碍程序的终止。一旦一个应用程序的所有前台线程都终止后,CLR 就通过调用任意一个存活中的后台线程的Abort()方法来彻底终止应用程序。另外 Thread 类还有一个IsAlive 属性,这是个只读属性,用来说明线程已经启动,还没有结束。
6.线程的同步
(1)线程同步的概念
同上运行的线程,有的相互间没有任何联系,称为无关线程,而有些线程之间则是有联系的,例如一个线程等待另一个线程的运算结果,两个线程共享一个资源等,这种线程称为相关线程。例如在网上观看在线视频,一个线程下载视频,另一个线程播放视频,两个线程相互合作,才能得到较佳的观看体验。线程的相关性集中体现在对同一资源的访问上,把这种多个线程共享的资源称为临界资源,它可以是内存中的一个变量,也可以是一个文件,也可以是一台打印机等。
系统中往往有多个线程交替执行,它们被执行的时间是不确定,当需要两个线程精确协同工作才能共同完成好一项任务的情况称为线程同步(Synchronization)。如何保证两个线程同步?.NET框架提供了一系列的同步类,最常用的包括Interlocked(互锁)、Monitor(管程)和Mutex(互斥体)。
(2)互锁(Interlocked类)
通过Interlocked类来控制线程的同步,为多个线程共享的变量提供原子操作,所谓原子操作是指不会被线程调度机制打断的操作;这种操作一旦开始,就一直运行到结束。例如:
using System.Threading;
class Program
{ private static char buffer; //缓冲区,只能容纳一个字符
//标识量(缓冲区中已使用的空间,初始值为0 )
private static long numberOfUsedSpace = 0;
static void Main(string[] args)
{ Thread writer = new Thread(delegate()
{
string sentence = "无可奈何花落去,似曾相识燕归来,小园香径独徘徊。";
for(int i = 0; i < 24; i++)
{
//写入数据前检查缓冲区是否已满
while(Interlocked.Read(ref numberOfUsedSpace) == 1)
{Thread.Sleep(10);}
buffer = sentence[i]; //向缓冲区写入数据
Interlocked.Increment(ref numberOfUsedSpace);
}
});
Thread reader = new Thread(delegate()
{for(int i = 0; i < 24; i++)
{//读取数据前检查缓冲区是否为空
while(Interlocked.Read(ref numberOfUsedSpace) == 0)
{Thread.Sleep(10);}
char ch = buffer;
Console.Write(ch);
//读取数据后把缓冲区标记为空(由1 变为0)
Interlocked.Decrement(ref numberOfUsedSpace);
}
});
//启动线程
writer .Start();
reader.Start(); }
}
(3)Monitor类
另一种同步方法使用Monitor类,它使用独占锁的方式控制线程同步,只有获得独占锁的线程才能访问临界资源。当一个线程进入临界区时,首先调用Monitor 类的Enter()方法,尝试获取临界资源的独占锁,若独占锁已被其他线程占用,就进入等待状态,睡眠在临界资源上,直到独占锁没有被其他线程占用,该线程就会获取独占锁,执行操作临界资源的代码。Monitor 会纪录所有睡眠在临界资源上的线程,当线程退出临界区时,需要通过调用Monitor 类的Pulse()方法唤醒睡眠在临界资源的线程。Monitor 类的部分方法如下表所示:
using System.Threading;
class Program
{private static char buffer;
//用于同步的对象(独占锁)
private static object lockForBuffer = new object();
static void Main(string[] args)
{Thread writer = new Thread(delegate()
{ string sentence = "无可奈何花落去,似曾相识燕归来,小园香径独徘徊。";
for (int i = 0; i < 24; i++)
{ lock(lockForBuffer)
{ buffer = sentence[i];
Monitor.Pulse(lockForBuffer); //唤醒睡眠在此临界资源上的其它线程
Monitor.Wait(lockForBuffer); //让当前线程睡眠在临界资源上 }
}
});
Thread reader = new Thread(delegate()
{ for (int i = 0; i < 24; i++)
{ lock (lockForBuffer)
{ char ch = buffer;
Console.Write(ch);
Monitor.Pulse(lockForBuffer); //唤醒睡眠在临界资源上的线程
Monitor.Wait(lockForBuffer); //让当前线程睡眠在临界资源上}
}
});
//启动线程
writer .Start();
reader.Start();
} }
实际上,旧版的C#中其编码方式如下左图,新版的C#对其进行了简化,设计了lock(object o){}的方式。
在线程 writer 中,因为当缓冲区中的数据被读走以后还要继续向缓冲区中写数据,所以当写完数据后调用了Monitor.Wait()方法,让线程writer 睡眠在临界资源上,直到线程reader 读完数据后把它唤醒。出于同样的理由,当线程reader 读完数据后,也调用了Monitor.Wait()方法。为了确保退出临界区时临界资源得到释放,使用 Monitor 类的代码应该放在try 语句中,并在finally 块中调用Monitor.Exit()方法。而简化版的lock 语句,执行完毕后会自动执行Monitor.Exit()方法,释放临界资源,二者功能完全等价。
需要注意的是,Monitor 类只能锁定引用类型对象。
当一个线程以独占锁的方式访问资源时,其他线程就不能访问该资源,只有lock 语句结束后其他线程才能访问。lock 语句的相当于临时禁用了应用程序的多线程功能。一般情况下,当有多个线程对同一个资源进行写操作时,就应当进行同步操作。但是如果一个线程在某资源上放置了一把锁,其他访问该资源的线程就只能暂停,使程序的效率大打折扣。所以只有必要的时候才设置独占锁。
(4)互斥体(Mutex类)
在操作系统中,许多线程常常需要共享资源,而这些资源往往要求排他性的使用,即一次只能为一个线程服务。比如打印机一次只能打印一个文档,一个文件一次只允许一个线程写入数据等。这种排他性地使用共享资源称为线程间的互斥(Mutual Exclusion)。线程互斥实质上也是同步,可以看做一种特殊的线程同步。线程的互斥常用Mutex 类(互斥体)实现,利用它可以对资源进行独占性访问。与Monitor 类相似,只有获取Mutex 对象的所属权的线程才能进入临界区,未获得Mutex 对象所属权的线程只能在临界区外等待。使用Mutex 类要比使用Monitor 类消耗更多的系统资源,但它可以跨越应用程序边界,在多个应用程序之间进行同步。Mutex 类的部分方法如下表所示:
互斥体有两种类型:局部互斥体和系统互斥体。局部互斥体只能在创建它的程序中使用,而系统互斥体则能被系统中不同的应用程序共享。创建系统互斥体,只需在构造函数中为互斥体对象起一个“系统名称”即可。
操作系统根据互斥体的系统名称辨别互斥体,不管互斥体对象创建于哪个应用程序中,只要具有相同的系统名称,就被认为是同一个系统互斥体。下面分别创建两个程序,它们都每隔一秒钟向文件 TimeRecord.txt 中写入一条包含当前系统时间的记录。显然,为了保证每条记录的完整性,当一个程序向文件中写入记录时,另一个程序必须等待。因此需要用Mutex 进行同步。
static void Main(string[] args)
{ Thread threadA=new Thread(delegate()
{ Mutex fileMutex = new Mutex(false, "MutexForTimeRecordFile");
string fileName = @"D:\ TimeRecord.txt";
for (int i = 1; i <= 10; i++)
{try{
//请求互斥体的所属权,若成功,则进入临界区,若不成功,则等待
fileMutex.WaitOne();
//在临界区中操作临界资源,即向文件中写入数据
File.AppendAllText(fileName, "threadA: " + DateTime.Now + "\r\n");
}
catch (System.Threading.ThreadInterruptedException)
{Console.WriteLine("线程A 被中断。");}
finally
{fileMutex.ReleaseMutex(); //释放互斥体的所属权}
Thread.Sleep(1000);
}
});
threadA.Start();
}
//创建第二个程序"MutecB",在主函数中输入下面的代码
static void Main(string[] args)
{
Thread threadB = new Thread(delegate()
{//创建互斥体
Mutex fileMutex = new Mutex(false, "MutexForTimeRecordFile");
string fileName = @"D:\ TimeRecord.txt";
for (int i = 1; i <= 10; i++)
{
try
{//请求互斥体的所属权,若成功,则进入临界区,若不成功,则等待
fileMutex.WaitOne();
//在临界区中操作临界资源,即向文件中写入数据
File.AppendAllText(fileName, "threadB: " + DateTime.Now + "\r\n");
}
catch (System.Threading.ThreadInterruptedException)
{Console.WriteLine("线程B 被中断。");}
finally
{fileMutex.ReleaseMutex(); //释放互斥体的所属权}
Thread.Sleep(1000);
}
});
threadB.Start();
System.Diagnostics.Process.Start("MutexA.exe"); //启动程序MutexA.exe
}
上面两个程序中,我们分别创建了一个互斥体对象,因为它们的系统名称都是“MutexForTimeRecordFile”,所以操作系统认为它们是同一个Mutex 对象,从而实现两个应用程序互斥地访问同一个文件。用【生成】菜单中的命令分别生成程序 MutexA.exe 和程序MutexB.exe,然后把它们复制到同一个文件夹中。双击程序 MutexB.exe,因为程序MutexB.exe 中包含有启动程序MutexA.exe 的代码,所以两个程序都被启动。两个程序运行完毕后,打开TimeRecord.txt 文件,观察结果:两个程序实现了交替的向同一文件中写入时间记录。当一个程序向文件中写入文件记录时,另一个程序只能处于等待状态,从而保证了每条记录的完整性。
5.死锁
多个线程间的同步如果设计不当,就会造成死锁(Deadlock)。死锁是指多个线程共享某些资源时,都占用一部分资源,而且都在等待对方释放另一部分资源,从而导致程序停滞不前的情况。
下面的程序演示了一种典型的死锁情形。一对情侣共吃一份西餐,并且共用一副刀叉。只有同时获得刀子和叉子时,才可以吃东西,吃完以后就放下刀叉,供对方使用。如果刀子或叉子正好被对方拿起,就只能等待,直到对方放下为止。
class Program
{ private static object knife = new object(); //临界资源:刀子
private static object fork = new object(); //临界资源:叉子
static void Main(string[] args)
{//线程:女孩的行为
Thread girlThread = new Thread(delegate()
{//女孩和男孩聊天
Console.WriteLine("今天的月亮好美啊~~~");
//过了一会儿,女孩饿了,就去拿刀子和叉子
lock (knife)
{ GetKnife();
//*(待会儿会在这里添加一条语句)
lock (fork)
{ GetFork();
Eat(); //同时拿到刀子和叉子后开始吃东西
Console.WriteLine("女孩放下叉子");
Monitor.Pulse(fork); }
Console.WriteLine("女孩放下刀子");
Monitor.Pulse(knife); }
});
girlThread.Name = "女孩"; //定义线程的名称
//线程:男孩的行为
Thread boyThread = new Thread(delegate()
{ //男孩和女孩聊天
Console.WriteLine("\n 你更美!");
lock (fork)
{ GetFork();
lock (knife)
{ GetKnife();
Eat(); //同时拿到刀子和叉子后开始吃东西
Console.WriteLine("男孩放下刀子");
Monitor.Pulse(knife); }
Console.WriteLine("男孩放下叉子");
Monitor.Pulse(fork);
}
});
boyThread.Name = "男孩"; //定义线程的名称
//启动线程
girlThread .Start();
boyThread.Start();
}
//方法:拿起刀子
static void GetKnife()
{Console.WriteLine(Thread.CurrentThread.Name + "拿起刀子。");}
//方法:拿起叉子
static void GetFork()
{Console.WriteLine(Thread.CurrentThread.Name + "拿起叉子。");}
//方法:吃东西
static void Eat()
{Console.WriteLine(Thread.CurrentThread.Name + "吃东西。");}
}
一般情况下,程序可以正常运行,结果如下:
但在某些特殊情况下就会出现死锁现象。假设女孩刚好拿起了刀子,正要拿叉子时,操作系统把线程切换到了男孩,这时男孩也想吃饭,于是拿起了叉子,但随即发现刀子已被女孩占有,所以男孩线程进入睡眠状态,等待女孩释放刀子。过一会儿,线程再次切换到女孩,当女孩试图拿起叉子时发现叉子已被男孩占有,于是女孩线程也进入睡眠状态,等待男孩释放叉子。最终男孩等女孩释放刀子,女孩等男孩释放叉子,双方都在无休止的等待对方,进入了死锁状态。
出现死锁的前提条件是两个线程出现交替,交替过程中各占有一部分资源(而每个线程运行都需要获得整个资源)由于例子中的两个线程都很小,多数情况下都能在一个时间片内完成,所以出现死锁的概率还是很小的。线程运行时间的越长,出现交替的情况越多,出现死锁的概率越大。
死锁会造成程序停滞不前,所以我们在编写多线程程序时一定要注意避免死锁现象的发生。其实上面的问题很好解决,只要两个线程以相同的顺序访问临界资源即可。
7.线程池
一般情况下我们都使用Thread类创建线程,因为通过Thread对象可以对线程进行灵活的控制。但创建线程和销毁线程代价不菲,过多的线程会消耗掉大量的内存和CPU资源,假如某段时间内突然爆发了100 个短小的线程,创建和销毁这些线程就会消耗很多时间,可能比线程本身运行的时间还长。为了改善这种状况,.NET提供了一种称之为线程池Thread Pool)的技术。线程池提供若干个固定线程轮流为大量的任务服务,比如用10 个线程轮流执行100 个任务,当一个线程完成任务时,并不马上销毁,而是接手另一个任务,从而减少创建和销毁线程的消耗。线程池由System.Threading 命名空间中的ThreadPool 类实现,其部分方法如下表所示:
ThreadPool 是一个静态类,不必创建实例就可以使用它。一个应用程序最多只有一个线程池,它会在首次向线程池中排入工作函数时自动创建。
namespace ThreadPoolTest
{
class Program
{
public delegate void WaitCallback(Object dataForFunction);
public static void ThreadPoolTest()
{//向线程池中添加100个工作线程
for (int i = 1; i <= 100; i++)
{ThreadPool.QueueUserWorkItem(new WaitCallback(WorkFunction), i);}
}
//工作函数
public static void WorkFunction(object n)
{ Console.Write(n + "\t");}
static void Main(string[] args)
{ThreadPoolTest();
Console.ReadKey(); //按下任意键结束程序}
}}
结果如下图所示:
下面,研究一下线程池运行过程中线程数目的变化情况,从而加深对线程池的理解。为了叙述方便,假设下限为10,上限为30。
①当线程池被创建后,里面就会创建10 个空线程(和下限值相同)。
②当向线程池中排入一个任务后,就会有一个空线程接手该任务,然后运行起来。随着不断向线程池中排入任务,线程池中的空线程逐一运行起来。
③随任务不断增加,某一时刻任务数量会超出下限,这时线程数量不够用了,但线程池并不会立即创建新线程,而是等待500 毫秒左右,看看在这段时间是否有其它线程完成任务并接手这个请求,避免因创建新线程而造成的消耗。如果这段时间没有线程完成任务,就创建一个新线程去执行新任务。
④在任务数量超过下限后,随着新任务的不断排入,线程池中线程数量持续增加,直至达到上限值为止。
⑤当线程数量达到上限时,继续增加任务,线程数量将不再增加。多余的任务就线程池外排队等待。线程池某个线程完成任务后,就从等待队列中选择一个任务继续执行。
⑥随着任务逐步完成,线程池外部等候的任务被逐步调入线程池,任务的数量逐步减少,但线程的总数保持恒定,始终为30(和上限值相同)。
⑦随着任务的逐渐减少,某一时刻任务数量会小于上限值,这时线程池内多余的线程会在空闲2 分钟后被释放并回收相关资源。线程数目逐步减少,直到达到下限值。
⑧当任务数量减小到下限值之下时,线程池中的线程数目保持不变(始终和下限值相同),其中一部分在执行任务,另一部分处于空运行状态。
⑨当所有任务都完成后,线程池恢复初始状态,运行10 个空线程。
由上面的论述可以看出线程池提高效率的关键是一个线程完成任务后可以继续为其他任务服务,这样就可以使用有限的几个固定线程轮流为大量的任务服务,从而减少了因频繁创建和销毁线程所造成的消耗。ThreadPool 中的线程不用手动开始,也不能手动取消,你要做的只是把工作函数排入线程池,剩下的工作将由系统自动完成。如果想对线程进行更多的控制,那么就不适合使用线程池。在以下情况中不宜使用ThreadPool类而应该使用单独的Thread 类:
①线程执行需要很长时间(如果有些线程长期占用线程池,那么对在外面排队的任务说就是灾难);
②需要为线程指定详细的优先级;
③在执行过程中需要对线程进行操作,比如睡眠,挂起等。
所以 ThreadPool 适合于并发运行若干个运行时间不长且互不干扰的函数。