[转]C#中的多线程 - 基础知识(一)
1 简介及概念
·C# 支持通过多线程并行执行代码,线程有其独立的执行路径,能够与其它线程同时执行。
·一个 C# 客户端程序(Console 命令行、WPF 以及 Windows Forms)开始于一个单线程,这个线程(也称为“主线程”)是由 CLR 和操作系统自动创建的,并且也可以再创建其它线程。以下是一个简单的使用多线程的例子:
所有示例都假定已经引用了以下命名空间:
>using System;
>using System.Threading;
class ThreadTest
{
static void Main()
{
Thread t = new Thread(WriteY); // 创建新线程
t.Start(); // 启动新线程,执行WriteY()
// 同时,在主线程做其它事情
for (int i = 0; i < 1000; i++) Console.Write("x");
}
static void WriteY()
{
for (int i = 0; i < 1000; i++) Console.Write("y");
}
}
输出结果:
xxxxxxxxxxxxxxxxyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy
xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxyyyyyyyyyyyyy
yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyxxxxxxxxxxxxxxxxxxxxxx
...
主线程创建了一个新线程t来不断打印字母 “ y “,与此同时,主线程在不停打印字母 “ x “。
线程一旦启动,线程的IsAlive属性值就会为true,直到线程结束。当传递给Thread的构造方法的委托执行完成时,线程就会结束。一旦结束,该线程不能再重新启动。
CLR 为每个线程分配各自独立的栈空间,因此局部变量是独立的。在下面的例子中,我们定义一个拥有局部变量的方法,然后在主线程和新创建的线程中同时执行该方法。
static void Main()
{
new Thread(Go).Start(); // 在新线程执行Go()
Go(); // 在主线程执行Go()
}
static void Go()
{
// 定义和使用局部变量 - 'cycles'
for (int cycles = 0; cycles < 5; cycles++) Console.Write('?');
}
输出结果:??????????
变量cycles的副本是分别在各自的栈中创建的,因此才会输出 10 个问号。
线程可以通过对同一对象的引用来共享数据。例如:
class ThreadTest
{
bool done;
static void Main()
{
ThreadTest tt = new ThreadTest(); // 创建一个公共的实例
new Thread(tt.Go).Start();
tt.Go();
}
// 注意: Go现在是一个实例方法
void Go()
{
if (!done) { done = true; Console.WriteLine("Done"); }
}
}
由于两个线程是调用了同一个的ThreadTest实例上的Go(),它们共享了done字段,因此输出结果是一次 “ Done “,而不是两次。
输出结果:Done
静态字段提供了另一种在线程间共享数据的方式,以下是一个静态的done字段的例子:
class ThreadTest
{
static bool done; // 静态字段在所有线程中共享
static void Main()
{
new Thread(Go).Start();
Go();
}
static void Go()
{
if (!done) { done = true; Console.WriteLine("Done"); }
}
}
以上两个例子引出了一个关键概念线程安全(thread safety)。上述两个例子的输出实际上是不确定的:” Done “ 有可能会被打印两次。如果在Go
方法里调换指令的顺序,” Done “ 被打印两次的几率会大幅提高:
static void Go()
{
if (!done) { Console.WriteLine("Done"); done = true; }
}
输出结果:
Done
Done(很可能!)
这个问题是因为一个线程对if中的语句估值的时候,另一个线程正在执行WriteLine语句,这时done还没有被设置为true。
修复这个问题需要在读写公共字段时,获得一个排它锁(互斥锁,exclusive lock )。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; }
}
}
}
当两个线程同时争夺一个锁的时候(例子中的locker),一个线程等待,或者说阻塞,直到锁变为可用。这样就确保了在同一时刻只有一个线程能进入临界区(critical section,不允许并发执行的代码),所以 “ Done “ 只被打印了一次。像这种用来避免在多线程下的不确定性的方式被称为线程安全(thread-safe)。
在线程间共享数据是造成多线程复杂、难以定位的错误的主要原因。尽管这通常是必须的,但应该尽可能保持简单。
一个线程被阻塞时,不会消耗 CPU 资源。
1.1 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");
}
输出 “ y “ 1,000 次之后,紧接着会输出 “ Thread t has ended! “。当调用Join时可以使用一个超时参数,以毫秒或是TimeSpan形式。如果线程正常结束则返回true,如果超时则返回false。
Thread.Sleep会将当前的线程阻塞一段时间:
Thread.Sleep (TimeSpan.FromHours (1)); // 阻塞 1小时
Thread.Sleep (500); // 阻塞 500 毫秒
当使用Sleep或Join等待时,线程是阻塞(blocked)状态,因此不会消耗 CPU 资源。
Thread.Sleep(0)会立即释放当前的时间片,将 CPU 资源出让给其它线程。Framework 4.0 新的Thread.Yield()方法与其相同,除了它只会出让给运行在相同处理器核心上的其它线程。
Sleep(0)和Yield在调整代码性能时偶尔有用,它也是一个很好的诊断工具,可以用于找出线程安全(thread safety)的问题。如果在你代码的任意位置插入Thread.Yield()会影响到程序,基本可以确定存在 bug。
1.2 线程是如何工作的
线程在内部由一个线程调度器(thread scheduler)管理,一般 CLR 会把这个任务交给操作系统完成。线程调度器确保所有活动的线程能够分配到适当的执行时间,并且保证那些处于等待或阻塞状态(例如,等待排它锁或者用户输入)的线程不消耗CPU时间。
在单核计算机上,线程调度器会进行时间切片(time-slicing),快速的在活动线程中切换执行。在 Windows 操作系统上,一个时间片通常在十几毫秒(译者注:默认 15.625ms),远大于 CPU 在线程间进行上下文切换的开销(通常在几微秒区间)。
在多核计算机上,多线程的实现是混合了时间切片和真实的并发,不同的线程同时运行在不同的 CPU 核心上。几乎可以肯定仍然会使用到时间切片,因为操作系统除了要调度其它的应用,还需要调度自身的线程。
线程的执行由于外部因素(比如时间切片)被中断称为被抢占(preempted)。在大多数情况下,线程无法控制其在何时及在什么代码处被抢占。
1.3 线程 vs 进程
好比多个进程并行在计算机上执行,多个线程是在一个进程中并行执行。进程是完全隔离的,而线程是在一定程度上隔离。一般的,线程与运行在相同程序中的其它线程共享堆内存。这就是线程为何有用的部分原因,一个线程可以在后台获取数据,而另一个线程可以同时显示已获取到的数据。
1.4线程的使用与误用
多线程有许多用处,下面是通常的应用场景:
维持用户界面的响应
使用工作线程并行运行时间消耗大的任务,这样主UI线程就仍然可以响应键盘、鼠标的事件。
有效利用 CPU
多线程在一个线程等待其它计算机或硬件设备响应时非常有用。当一个线程在执行任务时被阻塞,其它线程就可以利用这个空闲出来的CPU核心。
并行计算
在多核心或多处理器的计算机上,计算密集型的代码如果通过分治策略(divide-and-conquer,见第 5 部分)将工作量分摊到多个线程,就可以提高计算速度。
推测执行(speculative execution)
在多核心的计算机上,有时可以通过推测之后需要被执行的工作,提前执行它们来提高性能。LINQPad就使用了这个技术来加速新查询的创建。另一种方式就是可以多线程并行运行解决相同问题的不同算法,因为预先不知道哪个算法更好,这样做就可以尽早获得结果。
允许同时处理请求
在服务端,客户端请求可能同时到达,因此需要并行处理(如果你使用 ASP.NET、WCF、Web Services 或者 Remoting,.NET Framework 会自动创建线程)。这在客户端同样有用,例如处理 P2P 网络连接,或是处理来自用户的多个请求。
如果使用了 ASP.NET 和 WCF 之类的技术,可能不会注意到多线程被使用,除非是访问共享数据时(比如通过静态字段共享数据)。如果没有正确的加锁,就可能产生线程安全问题。
多线程同样也会带来缺点,最大的问题是它提高了程序的复杂度。使用多个线程本身并不复杂,复杂的是线程间的交互(一般是通过共享数据)。无论线程间的交互是否有意为之,都会带来较长的开发周期,以及带来间歇的、难以重现的 bug。因此,最好保证线程间的交互尽量少,并坚持简单和已被证明的多线程交互设计。这篇文章主要就是关于如何处理这种复杂的问题,如果能够移除线程间交互,那会轻松许多。
一个好的策略是把多线程逻辑使用可重用的类封装,以便于独立的检验和测试。.NET Framework 提供了许多高层的线程构造,之后会讲到。
当频繁地调度和切换线程时(并且如果活动线程数量大于 CPU 核心数),多线程会增加资源和 CPU 的开销,线程的创建和销毁也会增加开销。多线程并不总是能提升程序的运行速度,如果使用不当,反而可能降低速度。 例如,当需要进行大量的磁盘 I/O 时,几个工作线程顺序执行可能会比 10 个线程同时执行要快。(在使用Wait和Pulse进行同步中,将会描述如何实现 生产者 / 消费者队列,它提供了上述功能。)
参考文献:
http://www.codeproject.com/Articles/98346/Microsecond-and-Millisecond-NET-Timer
http://www.codeproject.com/Articles/571289/Obtaining-Microsecond-Precision-in-NET
http://www.pinvoke.net/default.aspx/winmm/timeSetEvent.html
http://www.geisswerks.com/ryan/FAQS/timing.html
http://blog.gkarch.com/topic/threading.html
http://omeg.pl/blog/2011/11/on-winapi-timers-and-their-resolution/
https://randomascii.wordpress.com/2013/07/08/windows-timer-resolution-megawatts-wasted/
http://www.windowstimestamp.com/description