网站首页/技术开发列表/内容

初识C#线程

技术开发2024-04-08阅读
作者: BUIILDER.COM

使用多线程技术能有效地帮助你实现应用程序的更高性能和更优良的可伸缩性。但在真正运用这项技术的时候务必小心。本文是对线程技术所牵扯的工具和技术问题系列文章的开篇。我首先对线程概念进行介绍,然后总结一些常用的构造,最后介绍它们的用法。

线程的两面性

用Java语言编写多线程程序并不难,这是好事也是坏事。微软在开发C#时,他们把这种易用性的窘境全盘照搬到了整个新平台上。同时,C#相比Java具有更多的程序原语,但是Thread对象和同步监视器的基本Java原语从形式和功能上看都已足够提供强大的线程编程能力了。因此,在决定为应用程序采用多线程技术之前务必小心。

为什么不用多线程
首先得记住,在决定是否采用多线程技术时,除非你正在玩代码,否则千万别因为多线程编程够“酷”而简单地使用线程技术编程。多线程编程技术太时髦了,如果你不小心点你的老板迟早也会着迷,那时你就死定了。其次,不要因为让程序运行得更快而轻易采用多线程,除非你真的能证明单线程实现确实慢得可以。最后,在冒昧地一头扎进多线程机制之前,先回忆下微软所提供的一种公寓(apartment)模型,也就是把对象写成单线程构造而运行在多线程环境下。所以,说来说去,你并不一定非要采用多线程编码。不过,公寓模型是另外一个话题了。

如果做得不对,多线程编程势必会打开“潘朵拉的盒子”(意思是说惹出无数的麻烦)。重复性不明显、产生程序垃圾、记数器没有正确增值等等。你的应用程序还可能突然挂起。例如,数据库连接这类资源就可能出人意料地关闭或者变得过载。高级开发人员所面临的一个大麻烦就是解决线程问题。这些大问题不花点时间休想解决,而且它们对产品交货日期以及产品可靠性产生了严重的负面影响。

为什么要用多线程
如果你的应用程序需要采取以下的操作,那么你尽可在编程的时候考虑多线程机制:

连续的操作,需要花费忍无可忍的过长时间才可能完成
并行计算
为了等待网络、文件系统、用户或其他I/O响应而耗费大量的执行时间
所以说,在动手之前,先保证自己的应用程序中是否出现了以上3种情形。

如果你的代码运行得足够快,但是你认为你能让它运行得更快(假设你确实有这本事),我劝你最好不要接受这种诱惑。如果你不能肯定程序的计算操作并行性(例如针对同一数据表的并发数据库更改——当你的数据库达到了数据表级锁定的情况下),那么再想想其他法子吧。还有,如果你不知道应用程序是否因为等待输入或输出而花费了过多的时间,那么请首先搞清楚真正耗费时间的情况再说。实际上,启动3个线程以百万分之一的步长计算圆周率所消耗的时间就比同一线程重复计算3次要长得多。为什么会出现这种失败的情形呢?原因就在于,虽然第2条并行计算确实可用,但设计者却恰恰忽略了以上第3个标准:并行计算可以用到的一次计算期间却没有空闲周期。

假如你在为一台装备了多个处理器的并行计算机编写程序,则以上规则在这种情况下例外,你可以通过适当的并行操作设计而令软件性能大大获益——哪怕每一操作都对CPU时间极其贪婪。

基本的线程管理工具
刚才我已经为多线程编程提出了相当程度的警告,同时还为何时使用或者不使用多线程提出了建议,接下来我对多线程编程所能利用的某些工具进行阐述。

Thread对象

.NET库提供了一种名为System.Threading.Thread的对象,这种对象代表了单一线程。你可以启动线程、在当前线程继续运行的情况下设法完成线程的任务。这对那些需要打印文档或者保存大型文件但希望获得用户确认请求并给用户返回控制的应用程序来说帮助实在太大了。我们通过程序清单A演示了这一机制。

程序清单A


using System;
using System.Threading;
namespace Threads1 {
class Listing1 {
static void SayHello() { 
 Console.WriteLine("Hello, ");
 Thread.Sleep(750 /*mSec */);
 Console.WriteLine("World");
 }
static void Main(string[] args) {
Thread t1 = new Thread(new ThreadStartSayHello));
t1.Start();
Console.WriteLine("Thread started. Main done.");
}
 }
}




我们首先创建了一种方法:SayHello,由它完成我们的任务——显示问候语。它的签名必须匹配 System.Threading.ThreadStart指派(delegate)。注意,SayHello 方法调用了Thread.Sleep(int numMillisecs)方法。这是一种相当有用的构造而且会经常出现在这类示例中。

在主程序中,我们通过带SayHello方法的ThreadStart指派创建了一个新线程,并在该线程上调用Start方法。我们创建的线程随之被启动,然后我们的主线程在这个例子中继续运行到结束。

在很多情况下你可能要在各个线程中分别执行存在轻微差别的任务,同时需要把某种参数从一种任务所在的线程传递给另一任务所在的线程。要完成这一目标可以采取好几种合理的方式,最直接的做法就是创建一种Task对象,由它保存线程、特有的参数以及提供ThreadStart指派的worker方法。利用worker方法即可读取所提供的参数,因为它正好就是Task对象的成员所以对线程当然是唯一的。通过令线程成为一种公共字段,你就可以获得访问线程所有成员的权限而不必编写额外的封装代码了。请参看程序清单B 阅读这一技术的有关示例。

程序清单B
using System;
using System.Threading;
namespace TaskDemo {
 public class MyTask {
public Thread m_thread;
string m_name;
public MyTask(string name) {
m_name = name;
m_thread = new Thread(new ThreadStart(Worker));
 }
private void Worker() {
Console.WriteLine("Hello, ");
 Thread.Sleep(1500);
Console.WriteLine(m_name);
}
 } 
class TaskDemo1 {
 static void Main(string [] args){ 
 MyTask task1 = new MyTask("Bill");
 MyTask task2 = new MyTask("Steve");
 task1.m_thread.Start();
 task2.m_thread.Start();
 }
}
}




你甚至可以通过在保存线程的任务中定义字段的方法提供Task对象的某种返回值,在线程完成前设置这一返回值,最后在这项任务完成以后从启动这项任务的线程读取它。

你可以暂停一个线程、等待其他线程完成其任务。你可以在打算采集返回结果的时候执行两种操作,在三个分隔的线程之间执行数据库更新但直到所有线程都结束时才想进行数据处理也可以采用以上两种操作。该技术如程序清单C所示。

程序清单C:
http://builder.com.com/utils/sidebar.jhtml?id=u00220020531pcb01.htm&index=3


这里,我们采用了程序清单A的代码创建程序。这次我们运行两个线程,每一个线程完成同以前一样的任务。调用两个线程的Start () 方法之后调用它们的Join()方法。对线程调用Join()方法会令调用线程暂停执行直到被调用线程结束。因此thread1.Start ()方法会令主线程暂停直到thread1完成。然后我们对thread2执行同样的操作。结果,主线程直到thread1和 thread2都完成了才最后完成。

这个例子的思想分为两部分。首先,某一个线程不能调用另一线程上的Join方法除非后者已经启动。第二,有多于两种形式的Join可以设定调用线程继续运行的超时时间哪怕被调用线程仍在运行。

计算机科学中经常会提到看门狗概念,所谓看门狗(watchdog)其实就是负责保证功能正确性或者处理不正确功能的实体。另一种实体,也就是常用的看门狗计时器(watchdog timer)则通常负责保证另一任务在合理的时间内按时完成。程序清单D所示就是实现看门狗计时器的简单实现机制。

程序清单D:
http://builder.com.com/utils/sidebar.jhtml?id=u00220020531pcb01.htm&index=4

thread1启动之后我们就加入该线程但提供了10秒钟的超时时间。因为thread1内置15秒暂停设置,所以在加入超期之后还会继续存活。主线程则测试thread1.IsAlive,如果它还活动则终止线程。

同步和监视器
同步指的是保证一次一节代码中只有一个线程在执行的措施。讨论同步技术已经超出了本文所涉及的主题范围,但单个线程模块之内为可靠起见实际上会产生为数不少的构造。然而,它们中的大多数在这些代码块的外部,在大多数时间内都工作很正常,这样,我们一直所熟知的“如果编译通过并且得到我期望的答案那么它就是正确的”这种说法在这里就不一定成立了。这就是多线程为什么如此危险的部分原因。

监视器是最基础的同步构造。任何对象都有自己关联的监视器,一个监视器只能分配给一个对象。监视器上有一个“锁(lock)”,这个锁可以在某一时刻被唯一线程获得。在另一线程可以获得这把锁之前必须先释放它。你可以声明某个对象对所有线程可见来保护某一段代码,比如类字段等。你还可以在实施某种操作之前让某段代码从监视器那里获得对象锁,然后在操作完成之后释放这把锁。该构造的示范如程序清单E所示。

程序清单E:
http://builder.com.com/utils/sidebar.jhtml?id=u00220020531pcb01.htm&index=5

这里我们声明了一个对象myLockObject,该对象的唯一目的就是提供同步监视器。在 SayHello方法中,无论何时只要需要我们就允许两个线程打印出“Hello”的字样。然而,现在我们就通过myMonitorObject所关联的监视器控制了“Wonderful”和“World”的打印,这样,一个线程在被允许开始打印之前另一线程必须完成两次打印。

实现以上机制还可以采用另两种技术——lock()关键词何MethodImplAttribute属性。示例请参看程序清单F。

程序清单F:
http://builder.com.com/utils/sidebar.jhtml?id=u00220020531pcb01.htm&index=6

我们用lock(…){ … }代替Monitor.Enter(…) and Monitor.Exit(…)构造。这些构造在效果上是相同的,只不过后者相比前者更为便捷些。我们还增加了一个方法SayHello2 (),它具有属性MethodImpl。这一属性指定了将被同步的全部方法。实质等价于在包含这些同步方法的类型对象被允许调用方法之前强迫调用代码获取类型对象关联的监视器上的锁。这比在lock(){…}语句中封装方法代码可清楚多了。注意,文档中定义该属性为MethodImplAttribute,但它的实现却叫做MethodImpl。根据声明属性的陈述习惯,也许微软的某个开发人员自己可能没注意到这一疏忽。

小结
这篇文章的内容涵盖很多方面的问题。我已经讨论了采用或者不采用多线程技术的若干理由,同时还展示了某些用在多线程编程中的原语构造。此外我还介绍了线程对象并解释了运行若干线程完成任务的原理、什么是监视器以及如何通过监视器的使用完成代码的同步。在特定的情况下,lock关键词和MethodImpl属性完成同样的工作。

在后续的文章里我将继续描述其它基本构造,实现一个线程池,并且探讨更多的构造类型, 例如线程本地存储和重叠I/O等。



相关阅读