C#为多个线程的同步提供了自己的关键字:lock语句。lock语句是设置锁定和解除锁定的一种简单方式。在添加lock语句之前,先进入另一个争用条件。SharedState类说明了如何使用线程之间的共享状态,并共享一个整数值。
class SharedState
{
public int State { get; set; }
}
下面所有同步示例的代码(SingletonWPF除外)都使用如下名称空间:
System
System.Collections.Generic
System.Linq
System.Text
System.Threading
System.Threading.Tasks
Job类包含DoTheJob()方法,该方法是新任务的入口点。通过其实现代码,将SharedState对象的State递增50000次。SharedState变量在这个类的构造函数中初始化:
class Job
{
public Job(SharedState sharedState)
{
_sharedState = sharedState;
}
private SharedState _sharedState;
public void DoTheJob()
{
for (int i = 0; i < 5000; i++)
{
_sharedState.State += 1;
}
}
}
在Main()方法中,创建一个SharedState对象,并把它传递给20个Task对象的构造函数。在启动所有的任务后,Main()方法进入另一个循环,等待20个任务都执行完毕。任务执行完毕后,把共享状态的合计值写入控制台中。因为执行了50000次循环,有20个任务,所以写入控制台的值应是1 000 000。但是,事实常常并非如此。
class Program
{
static void Main(string[] args)
{
for (int num = 0; num < 5; num++) //定义运行5次
{
int numTasks = 20;
var state = new SharedState();
var tasks = new Task[numTasks];
for (int i = 0; i < numTasks; i++)
{
tasks[i] = Task.Run(() => new Job(state).DoTheJob());
}
Task.WaitAll(tasks);
Console.WriteLine($"summarized {state.State}");
}
}
}
多次运行应用程序的结果如下所示:
summarized 256734
summarized 269939
summarized 271407
summarized 321613
summarized 465098
每次运行的结果都不同,但没有一个结果是正确的。如前所述,调试版本和发布版本的区别很大。根据使用的CPU类型,结果也不一样。如果将循环次数改为比较小的值,就会多次得到正确的值,但不是每次都正确。这个应用程序非常小,很容易看出问题,但该问题的原因在大型应用程序中就很难确定。
上一个示例用lock锁定后,代码块和运行结果:
class Job
{
public Job(SharedState sharedState)
{
_sharedState = sharedState;
}
private SharedState _sharedState;
public void DoTheJob()
{
for (int i = 0; i < 50000; i++)
{
lock (_sharedState)
{
_sharedState.State += 1;
}
}
}
}
summarized 1000000
summarized 1000000
summarized 1000000
summarized 1000000
summarized 1000000
必须在这个程序中添加同步功能,这可以用lock关键字实现。用lock语句定义的对象表示,要等待指定对象的锁定。只能传递引用类型。锁定值类型只是锁定了一个副本,这没有什么意义。如果对值类型使用了lock语句,C#编译器就会发出一个错误。进行了锁定后——只锁定了一个线程,就可以运行lock语句块。在lock语句块的最后,对象的锁定被解除,另一个等待锁定的线程就可以获得该锁定块了。
lock (obj)
{
// synchronized region
}
要锁定静态成员,可以把锁放在object类型或静态成员上:
lock (typeof(StaticClass))
{
}
使用lock关键字可以将类的实例成员设置为线程安全的。这样,一次只有一个线程能访问相同实例的DoThis()和DoThat()方法。
class Demo
{
public void DoThis()
{
lock (this)
{
// only one thread at a time can access the DoThis and DoThat methods
}
}
public void DoThat()
{
lock (this)
{
}
}
}
但是,因为实例的对象也可以用于外部的同步访问,而且我们不能在类自身中控制这种访问,所以应采用SyncRoot模式。通过SyncRoot模式,创建一个私有对象_syncRoot,将这个对象用于lock语句。
class Demo
{
private object _syncRoot = new object();
public void DoThis()
{
lock (_syncRoot)
{
// only one thread at a time can access the DoThis and DoThat methods
}
}
public void DoThat()
{
lock (_syncRoot)
{
}
}
}
注意:在Job类中,使用SyncRoot模式或this,输出的仍是非预期的结果。对于SharedState类有效。如下代码片段所示:
class Job
{
public Job(SharedState sharedState)
{
_shareState = sharedState;
}
private SharedState _shareState;
private object _asyncRoot = new object();
public void DoTheJob()
{
for (int i = 0; i < 5000; i++)
{
lock (_asyncRoot)
{
_shareState.IncrementState();
}
//lock (this)
//{
//}
}
}
}
使用锁定需要时间,且并不总是必要的。可以创建类的两个版本,一个同步版本,一个异步版本。下一个示例通过修改Demo类来说明。Demo类本身并不是同步的,这可以在DoThis()和DoThat()方法的实现中看出。该类还定义了IsSynchronized属性,客户可以从该属性中获得类的同步选项信息。为了获得该类的同步版本,可以使用静态方法Synchronized()传递一个非同步对象,这个方法会返回SynchronizedDemo类型的对象。SynchronizedDemo实现为派生自基类Demo的一个内部类,并重写基类的虚成员。重写的成员使用了SyncRoot模式。
class Demo
{
private class SynchronizedDemo:Demo
{
private object _syncRoot = new object();
private Demo _d;
public SynchronizedDemo(Demo d)
{
_d = d;
}
public override bool IsSynchronized => true;
public override void DoThis()
{
lock (_syncRoot)
{
_d.DoThis();
}
}
public override void DoThat()
{
lock (_syncRoot)
{
_d.DoThat();
}
}
}
public virtual bool IsSynchronized => false;
public static Demo Synchronized(Demo d)
{
if (!d.IsSynchronized)
{
return new SynchronizedDemo(d);
}
return d;
}
public virtual void DoThis()
{
}
public virtual void DoThat()
{
}
}
必须注意,在使用SynchronizedDemo类时,只有方法是同步的。对这个类的两个成员的调用并没有同步。
首先修改异步的SharedState类,以使用SyncRoot模式。如果试图用SyncRoot模式锁定对属性的访问,使SharedState类变成线程安全的,就仍会出现前面描述的争用条件。
class SharedState
{
private int _state = 0;
private object _syncRoot = new object();
public int State //there's still a race condition,don't do this!
{
get
{
lock (_syncRoot)
{
return _state;
}
}
set
{
lock (_syncRoot)
{
_state = value;
}
}
}
}
调用方法DoTheJob()的线程访问SharedState类的get存取器,以获得State的当前值,接着get存取器给State设置新值。在调用对象的get和set存取器期间,对象没有锁定,另一个线程可以获得临时值。
public void DoTheJob()
{
for (int i = 0; i < 50000; i++)
{
_sharedState.State += 1;
}
}
运行结果:
summarized 346902
summarized 426230
summarized 299191
summarized 298090
summarized 251107
所以,最好不改变SharedState类,让它依旧没有线程安全性。
class SharedState
{
public int State { get; set; }
}
然后在DoTheJob方法中,将lock语句添加到合适的地方:
public void DoTheJob()
{
for (int i = 0; i < 50000; i++)
{
lock (_sharedState)
{
_sharedState.State += 1;
}
}
}
这样,应用程序的结果就总是正确的。
注意:
在一个地方使用lock语句并不意味着,访问对象的其他线程都正在等待。必须对每个访问共享状态的线程显示地使用同步功能。
当然,还必须修改SharedState类的设计,并作为一个原子操作提供递增方式。这是一个设计问题——把什么实现为类的原子功能?下面的代码片段锁定了递增操作。
class SharedState
{
private int _state = 0;
private object _syncRoot = new object();
public int State => _state;
public int IncrementState()
{
lock (_syncRoot)
{
return ++_state;
}
}
}
改变相关代码,以及运行结果:
class Job
{
public Job(SharedState sharedState)
{
_sharedState = sharedState;
}
private SharedState _sharedState;
public void DoTheJob()
{
for (int i = 0; i < 50000; i++)
{
//lock (_sharedState)
//{
// _sharedState.State += 1;
//}
_sharedState.IncrementState();
}
}
}
summarized 1000000
summarized 1000000
summarized 1000000
summarized 1000000
summarized 1000000
锁定状态的递增还有一种更快的方式,如下节所示。