目录
线程的基础和概念
C#支持通过多线程并行执行任务。一个线程就是一个独立的执行单元,能和其它线程同时运行。
C#程序(控制台、WPF、WinFrom)从CLR自动创建的单个线程开始,运行(main)主线程,并通过该线程创建额外的线程。下面是一个简单的示例:
注:假设本文所有的例子都默认导入:
using System;
using System.Threading;
示例:
Thread t = new Thread(WriteY);
t.Start();
for (int i = 0; i < 1000; i++)
{
Console.ForegroundColor = ConsoleColor.Green;
Console.Write("x");
//Console.ResetColor();
}
static void WriteY()
{
for(int i = 0; i < 1000; i++)
{
Console.ForegroundColor = ConsoleColor.Red;
Console.Write("y");
//Console.ResetColor();
}
}
运行结果:
main线程创建了一个新的线程”t"用于打印1000个绿色的“y",同时自身也打印1000个红色的“x".
从上面的结果可以看出两个线程并不是完全轮流打印x和y,而且其颜色也不是完全符合我们的直觉:
所有的x都应该是红色,所有的y都应该是绿色。
这里还涉及到很多线程的知识,随着后面深入,我们应该可以实现我们理想的运行结果。

一旦线程开始运行,其IsAlive属性就会置为true,直到线程结束运行。一般来说,线程结束的条件是传入Thread构造函数的代理函数运行完成。线程一旦结束运行就无法重新运行。
CLR 为每个线程分配自己的内存堆栈,以便 局部变量保持独立。在下一个示例中,我们定义一个具有 一个局部变量,然后在主线程上同时调用该方法,并且 新创建的线程:
new Thread(Go).Start();
Go();
static void Go()
{
for(int i=0;i<5;i++)
{
Console.Write("牛");
}
}
运行结果必然是 10个”牛“。循环变量”i“在每个线程的内存堆栈,它们是独立的。
但如果多个线程引用了同一个实例对象,那么该对象就属于共享数据,例子如下:
class ThreadTest
{
bool done;
static void Main()
{
ThreadTest tt = new ThreadTest(); // Create a common instance
new Thread (tt.Go).Start();
tt.Go();
}
// Note that Go is now an instance method
void Go()
{
if (!done) { done = true; Console.WriteLine ("Done"); }
}
}
上面的代码结果只会有一个输出。因为两个线程都使用了同一个变量”done“,一但第一个线程执行完成,会将"done"设置为true,从而导致第二个线成无法进入if语句内部。
这里有个问题:有没有可能两个出现打印两个的情况?
静态字段提供了另一种在两个线程之间共享数据的办法:
class ThreadTest
{
static bool done; // Static fields are shared between all threads
static void Main()
{
new Thread (Go).Start();
Go();
}
static void Go()
{
if (!done) { done = true; Console.WriteLine ("Done"); }
}
}
这两个示例都说明了另一个关键概念: 线程安全(或者更确切地说,缺乏它! 输出实际上是不确定的:有可能(尽管不太可能)“Done” 可以打印两次。但是,如果我们交换方法中的语句顺序,则 “Done” 被打印两次的几率会增加 大幅:
static void Go()
{
if (!done) { Console.WriteLine ("Done"); done = true; }
}
问题在于一个线程在打印时(此时变量还未更改),另一个线程也进入了if语句内部。
补救措施就是在对共享变量进行读写时获得一个排他锁,C#提供了一个关键字”lock"来解决这个问题:
class ThreadSafe
{
static bool done;
static readonly object locker = new object();
static void Main()
{
new Thread (Go).Start();
Go();
}
static void Go()
{
lock (locker)
{
if (!done) { Console.WriteLine ("Done"); done = true; }
}
}
}
这下,无论你怎么运行,结果都是肯定的:只能打印一次。
当两个线程同时争用一个锁(在此 case, ),一个线程等待或阻塞,直到锁变为可用。在这种情况下, 它确保一次只有一个线程可以进入代码的关键部分。以这种方式保护的代码 消除多线程执行的不确定性称为线程安全。
lock是一种典型的排他锁。这就跟公共电话亭一样,我先进去了后来的只能等着,直到我离开(释放锁)后面的人才能进去。
共享数据的存在是导致多线程编程复杂性和易错的主要原因,所以多线程编程要尽可能简洁的实现
当一个线程等待获取锁的时候,它处于阻塞状态,阻塞状态下,该线程不消耗cpu资源。
Join & Sleep
你可以使用Join函数使得一个线程等待另一个线程结束后再结束。
看这个例子:
static void Main()
{
Thread t = new Thread (Go);
t.Start();
t.Join();
Console.WriteLine ("Thread t has ended!");
}
static void Go()
{
for (int i = 0; i < 1000; i++) Console.Write ("y");
}
运行上面的代码会在打印1000个'y'后输出最后一句。如果没有t.Join() 这与一句,那么程序不会等到输出1000个'y'后才打印,一定会在之前就结束运行。
Thread.Sleep 会暂停当前线程运行,具体暂停时间由参数决定。
一般用法:
Thread.Sleep (TimeSpan.FromHours (1)); // sleep for 1 hour
Thread.Sleep (500); //睡眠500毫秒
当一个线程在等待sleep和join 函数执行时,该线程处于阻塞状态,此时也不会消耗cpu资源
Thread.Sleep(0)
会立即主动放弃线程的当前时间片,将CPU让给其他线程。.NET Framework 4.0新增的Thread.Yield()
方法作用类似——但仅会让出给同一处理器上的其他线程。在高级性能调优场景中,
Sleep(0)
或Yield
有时能用于生产代码。它们同时也是发现线程安全问题的绝佳诊断工具:如果在代码任意位置插入Thread.Yield()
会导致程序运行结果改变,几乎可以确定存在并发缺陷。
多线程的工作原理
多线程在内部由线程调度器管理,这一功能通常由CLR委托给操作系统执行。线程调度器确保所有活动线程都能分配到适当的执行时间,同时保证处于等待或阻塞状态(例如在独占锁或用户输入上)的线程不会消耗CPU时间。
在单核处理器计算机上,线程调度器采用时间片轮转机制——快速地在各个活动线程之间切换执行。在Windows系统下,单个时间片通常为几十毫秒量级,远大于实际线程上下文切换所需的CPU开销(后者通常只需几微秒)。
在多核处理器计算机上,多线程通过时间片轮转与真实并发混合实现,不同线程可在不同CPU上同步执行代码。由于操作系统需要服务自身线程及其他应用程序线程,因此仍然会存在一定程度的时间片轮转。
当线程执行因时间片轮转等外部因素被中断时,称为被抢占。在多数情况下,线程无法控制自己被抢占的时机和位置。
线程 VS 进程
线程类似于运行应用程序的操作系统进程。正如多个进程能在计算机上并行运行一样,多个线程也能在单个进程内并行执行。不同进程之间完全隔离,而线程的隔离程度则较为有限——具体来说,同一应用程序中的线程会共享(堆)内存。这也正是线程的价值所在:例如,一个线程可以在后台获取数据,而另一个线程则能实时显示接收到的数据。
线程使用指南
保持用户界面响应灵敏
通过在独立的"工作线程"上执行耗时任务,主UI线程得以持续响应用户的键盘和鼠标操作。提升CPU资源利用率
当线程因等待远程计算机或硬件设备响应而阻塞时,多线程能充分利用闲置的CPU资源——其他线程可继续执行计算任务。并行计算
采用"分而治之"策略将计算密集型任务分配给多个线程,可显著提升多核/多处理器系统的运行效率(详见第五部分)。预测性执行
在多核设备上,通过预判可能需要的操作并提前执行可提升性能。LINQPad就采用此技术加速新查询创建。另一种变体是并行运行多个解决相同问题的算法,采用最先完成的方案——这在无法预判算法效率时尤为有效。并发请求处理
服务器需要并行处理同时到达的客户端请求(使用ASP.NET/WCF/Web服务/远程处理时,.NET框架会自动创建线程)。客户端同样适用此场景(例如P2P网络通信,甚至处理用户的多个并发请求)。
使用ASP.NET和WCF等技术时,开发者可能察觉不到多线程的存在——除非在未加锁的情况下访问共享数据(如静态字段),进而引发线程安全问题。
多线程技术也伴随约束条件,最显著的是会提升系统复杂度。线程数量本身并非复杂度的根源,真正的挑战在于线程间的交互(通常通过共享数据实现)。无论这种交互是否出于设计意图,都可能导致开发周期延长,并持续产生难以复现的间歇性缺陷。因此,应当尽可能减少线程交互,坚持使用简单可靠的设计方案。本文主要探讨的正是这些复杂性——若能消除线程交互,需要讨论的内容将大幅减少!
一个明智的策略是将多线程逻辑封装到可复用的类中,以便进行独立的检查和测试。事实上,.NET框架本身就提供了许多高级线程构造,我们将在后文详细介绍。
需要注意的是,线程调度和切换(当活跃线程数量超过CPU核心数时)会带来资源和CPU开销,同时线程的创建和销毁也需要成本。多线程并不总能提升应用程序性能——如果过度或不恰当地使用,反而可能导致性能下降。例如,在进行大量磁盘I/O操作时,让少量工作线程按顺序执行任务,可能比同时运行10个线程效率更高。(在后续"使用Wait和Pulse实现信号"章节中,我们将介绍如何实现具有这种功能的生产者/消费者队列。)
本小节完