要避免同步的问题,最好不要在线程之间共享数据。当然,这并不说是可行的。如果需要共享数据,就必须使用同步技术,确保一次只有一个线程访问和改变共享状态。注意同步问题与争用条件和死锁有关。如果不注意这些问题,就很难在应用程序中找到问题的原因,因为线程问题是不定期发生的。
1.1、lock和线程安全
C#为多个线程的同步提供了自己的关键字:lock语句。lock语句是设置锁定和解除锁定的一种简单方式。在添加lock语句之前,先进入另一个争用条件。SharedState类说明了如何使用线程之间的共享状态,并共享一个整数值。
public class SharedState
{
public int state { get; set; }
}
Job类包含DoJob()方法,该方法是新任务的入口点。通过其实现代码,将SharedState变量的State递增5000次。sharedState变量在这个类的构造函数中初始化。
public class job
{
SharedState sharedState;
public job(SharedState sharedState)
{
this.sharedState = sharedState;
}
public void doJob()
{
for (int i = 0; i < 5000; i++)
{
sharedState.state++;
}
}
}
在Main()方法中,创建一个SharedState对象,并把它传递给20个Task对象的构造函数。在启动的所有任务后,Main()方法进入另一个循环,等待20个任务都执行完毕。任务执行完毕后,把共享状态的合计值写入控制台中。因为执行了5000次循环,有20个任务,所以写入控制台的值应该是100000.但是,事实常常并非如此。
int numTask = 20;
var state = new SharedState();
var task = new Task[numTask];
for (int i = 0; i < numTask; i++)
{
task[i] = Task.Run(()=>new job(state).doJob());
}
for (int i = 0; i < numTask; i++)
{
task[i].Wait();
}
Console.WriteLine( "summarized {0}",state.state);
可以得到的是每次运行的结果都不同,但没有一个结果是正确的。如前所述,调式版本和发布版本的区别很大。根据使用的CPU类型,其结果也不一样。必须在这个程序中添加同步功能,这可以用lock关键字实现。用lock语句定义的对象表示,要等待指定对象的锁定。只能传递引用类型,锁定值类型只是锁定了一个副本,这没有什么意义。如果对值类型使用了lock语句,C#编译器就会发出一个错误。进行了锁定后---只锁定了一个线程,就可以运行lock语句块。在lock语句块的最后,对象的锁定被解除,另一个等待锁定的线程就可以获得该锁定块了。
lock(obj)
{
//sunchronized region
}
要锁定静态成员,可以把锁放在object类型上:
lock(object)
{
}
使用lock关键字可以将类的实例成员设置为线程安全的。这样,一次只有一个线程能访问相同实例的DoThis()和DoThat()方法。
public class Demo
{
public void DoThis()
{
lock(this)
{
//only one thread at a time can access the DoThis and DoThat method
}
}
public void DoThat()
{
lock (this)
{
}
}
}
但是,因为实例的对象也可以用于外部的同步访问,而且我们不能在类自身中控制这种访问,所以应采用SyncRoot模式。通过SyncRoot模式,创建一个私有对象,将这个对象用于lock语句
public class Demo
{
private object syncRoot = new object();
public void DoThis()
{
lock(syncRoot)
{
//only one thread at a time can access the DoThis and DoThat method
}
}
public void DoThat()
{
lock (syncRoot)
{
}
}
}
使用锁定需要时间,且并不总是必须的。前面的实例中将dojob方法里面的适当位置加lock,这样得到的结果总是对的。
public class job
{
SharedState sharedState;
public job(SharedState sharedState)
{
this.sharedState = sharedState;
}
public void doJob()
{
for (int i = 0; i < 5000; i++)
{
lock(sharedState)
sharedState.state++;
}
}
}
1.2、Interlocked类
Interlocked类用于使变量的简单语句原子化,I++不是线程安全的,他的操作包括从内存中获取一个值,给该值递增1,再将它存储到内存中。这些操作可能被线程调度器打断。Interlocked类提供了以线程安全的方式递增、递减、交换和读取值的方法。
与其同步技术相比,使用Interlocked类会快的多。但是,它只能用于简单的同步问题。 Interlocked.Increment();
1.3、Monitor类
lock语句由C#编译器解析为使用Monitor类。lock语句被解析为调用Enter()方法,该方法会一直等待,直到线程锁定对象为止。一次只有一个线程锁定对象,只要解除了锁定,线程就可以进入同步阶段。Monitor类的Exit()方法解除了锁定。编译器把Exit()方法放在try块的finally处理程序中,所以如果抛出了异常,就也会解除该锁定
Monitor.Enter(obj);
try
{
}
finally
{
Monitor.Exit(obj);
}
与C#的lock语句相比,Monitor类的主要优点是:可以添加一个等待被锁定的超时值。这样就不会无限期的等待锁定,而可以像下面的例子那样使用TryEnter()方法,其中给它传递一个超时值,指定等待被锁定的最长时间。如果obj被锁定,TryEnter()方法就把布尔型的引用参数设置为true,并同步地访问由对象obj锁定的状态。如果另一个线程锁定obj的时间超过了500毫秒,TryEnter()方法就把变量lockTaken设置为false,线程不再等待,而是用于执行其他操作。也许在以后,该线程会尝试再次获得锁定。
bool lockTaken = false;
Monitor.TryEnter(o,500,ref lockTaken);
if (lockTaken)
{
try
{
//acquried the lock
}
finally
{
Monitor.Exit(o);
}
}
else
{
//did not get the lock,do something else
}
1.4、SpinLock结构
如果基于对象的锁定对象(Monitor)的系统开销由于垃圾过高,就可以使用SpinLock结构。SpinLock结构是在.NET4开始引入的。如果有大量的锁定,且锁定的时间总是非常短,SpinLock结构就很有用。应避免使用多个SpinLock结构,也不要调用任何可能阻塞的内容。除了体系结构上的区别之外,SpinLock结构的用法非常类似于Monitor类。获得锁定使用Enter()或TryEnter()方法,释放锁定使用Exit()方法。SpinLock结构还提供了属性IsHeldByCurrentThread,指定它当前是否是锁定的。