正如前面所看到的一样,多个线程同时使用共享对象会造成很多问题。同步这些线程使得对共享对象的操作能够以正确的顺序执行是非常重要的。在使用C#中的lock关键字,我们遇到了一个叫作竞争条件的问题。导致这问题的原因是多线程的执行并没有正确同步。当一个线程执行递增和递减操作时,其他线程需要依次等待。这种常见问题通常被称为线程同步。
有多种方式来实现线程同步。首先,如果无须共享对象,那么就无须进行线程同步。令,人惊奇的是大多数时候可以通过重新设计程序来除移共享状态,从而去掉复杂的同步构造。请尽可能避免在多个线程间使用单一对象。
如果必须使用共享的状态,第二种方式是只使用原子操作。这意味着一个操作只占用一个量子的时间,一次就可以完成。所以只有当前操作完成后,其他线程才能执行其他操作。因此,你无须实现其他线程等待当前操作完成,这就避免了使用锁,也排除了死锁的情况。
如果上面的方式不可行,并且程序的逻辑更加复杂,那么我们不得不使用不同的方式来协调线程。方式之一是将等待的线程置于阻塞状态。当线程处于阻塞状态时,只会占用尽可能少的CPU时间。然而,这意味着将引入至少一次所谓的上下文切换( context switch),上下文切换是指操作系统的线程调度器。该调度器会保存等待的线程的状态,并切换到另一个线程,依次恢复等待的线程的状态。这需要消耗相当多的资源。然而,如果线程要被挂起很,长时间,那么这样做是值得的。这种方式又被称为内核模式(kernel-mode),因为只有操作系,统的内核才能阻止线程使用CPU时间。
万一线程只需要等待一小段时间,最好只是简单的等待,而不用将线程切换到阻塞状,态。虽然线程等待时会浪费CPU时间,但我们节省了上下文切换耗费的CPU时间。该方式又被称为用户模式(user-mode),该方式非常轻量,速度很快,但如果线程需要等待较长时间则会浪费大量的CPU时间。
为了利用好这两种方式,可以使用混合模式(hybrid),混合模式先尝试使用用户模式等,待,如果线程等待了足够长的时间,则会切换到阻塞状态以节省CPU资源。
执行基本的原子操作(Interlocked)
本节将展示如何对对象执行基本的原子操作,从而不用阻塞线程就可避免竞争条件。
internal class Program
{
private static void Main(string[] args)
{
Console.WriteLine("Incorrect counter");
var c = new Counter();
var t1 = new Thread(() => TestCounter(c));
var t2 = new Thread(() => TestCounter(c));
var t3 = new Thread(() => TestCounter(c));
t1.Start();
t2.Start();
t3.Start();
t1.Join();
t2.Join();
t3.Join();
Console.WriteLine("Total count: {0}", c.Count);
Console.WriteLine("--------------------------");
Console.WriteLine("Correct counter");
var c1 = new CounterNoLock();
t1 = new Thread(() => TestCounter(c1));
t2 = new Thread(() => TestCounter(c1));
t3 = new Thread(() => TestCounter(c1));
t1.Start();
t2.Start();
t3.Start();
t1.Join();
t2.Join();
t3.Join();
Console.WriteLine("Total count: {0}", c1.Count);
Console.ReadKey();
}
static void TestCounter(CounterBase c)
{
for (int i = 0; i < 100000; i++)
{
c.Increment();
c.Decrement();
}
}
class Counter : CounterBase
{
private int _count;
public int Count { get { return _count; } }
public override void Increment()
{
_count++;
}
public override void Decrement()
{
_count--;
}
}
class CounterNoLock : CounterBase
{
private int _count;
public int Count { get { return _count; } }
public override void Increment()
{
Interlocked.Increment(ref _count);
}
public override void Decrement()
{
Interlocked.Decrement(ref _count);
}
}
abstract class CounterBase
{
public abstract void Increment();
public abstract void Decrement();
}
}
当程序运行时,会创建三个线程来运行TestCounter方法中的代码。该方法对一个对象按序执行了递增或递减操作。起初的Counter对象不是线程安全的,我们会遇到竞争条件。所以第一个例子中计数器的结果值是不确定的。我们可能会得到数字0,然而如果运行程序多次,你将最终得到一些不正确的非零结果。在第1部分中,我们通过锁定对象解决了这个问题。在一个线程获取旧的计数器值并计,算后赋予新的值之前,其他线程都被阻塞了。然而,如果我们采用上述方式执行该操作中途不能停止。而借助于Interlocked类,我们无需锁定任何对象即可获取到正确的结果。Interlocked提供了Increment, Decrement和Add等基本数学操作的原子方法,从而帮助我们在编写Counter类时无需使用锁。
使用Mutex类
本节将描述如何使用Mutex类来同步两个单独的程序。Mutex是一种原始的同步方式,其只对一个线程授予对共享资源的独占访问。
class Program
{
static void Main(string[] args)
{
const string MutexName = "CSharpThreadingCookbook";
using (var m = new Mutex(false, MutexName))
{
if (!m.WaitOne(TimeSpan.FromSeconds(5), false))
{
Console.WriteLine("Second instance is running!");
}
else
{
Console.WriteLine("Running!");
Console.ReadLine();
m.ReleaseMutex();
}
}
}
}
当主程序启动时,定义了一个指定名称的互斥量,设置initialOwner标志为false。这意.味着如果互斥量已经被创建,则允许程序获取该互斥量。如果没有获取到互斥量,程序则简单地显示Running,等待直到按下了任何键,然后释放该互斥量并退出。
如果再运行同样一个程序,则会在5秒钟内尝试获取互斥量。如果此时在第一个程序中,按下了任何键,第二个程序则会开始执行。然而,如果保持等待5秒钟,第二个程序将无法,获取到该互斥量。
使用SemaphoreSlim类
本节将展示SemaphoreSlim类是如何作为Semaphore类的轻量级版本的。该类限制了同时访问同一个资源的线程数量。
class Program
{
static void Main(string[] args)
{
for (int i = 1; i <= 6; i++)
{
string threadName = "Thread " + i;
int secondsToWait = 2 + 2 * i;
var t = new Thread(() => AccessDatabase(threadName, secondsToWait));
t.Start();
}
}
static SemaphoreSlim _semaphore = new SemaphoreSlim(4);
static void AccessDatabase(string name, int seconds)
{
Console.WriteLine("{0} waits to access a database", name);
_semaphore.Wait();
Console.WriteLine("{0} was granted an access to a database", name);
Thread.Sleep(TimeSpan.FromSeconds(seconds));
Console.WriteLine("{0} is completed", name);
_semaphore.Release();
}
}
当主程序启动时,创建了SemaphoreSlim的一个实例,并在其构造函数中指定允许的并发线程数量。然后启动了6个不同名称和不同初始运行时间的线程。
每个线程都尝试获取数据库的访问,但是我们借助于信号系统限制了访问数据库的并发,数为4个线程。当有4个线程获取了数据库的访问后,其他两个线程需要等待,直到之前线,程中的某一个完成工作并调用semaphore.Release方法来发出信号。
这里我们使用了混合模式,其允许我们在等待时间很短的情况下无需使用上下文切换。然而,有一个叫作Semaphore的SemaphoreSlim类的老版本。该版本使用纯粹的内核时间 ( kernel-time)方式。一般没必要使用它,除非是非常重要的场景。我们可以创建一个具名的semaphore,就像一个具名的mutex一样,从而在不同的程序中同步线程。SemaphoreSlim并不使用Windows内核信号量,而且也不支持进程间同步。所以在跨程序同步的场景下可以使用Semaphore。
使用AutoResetEvent类
本示例借助于AutoResetEvent类来从一个线程向另一个线程发送通知。AutoResetEvent类可以通知等待的线程有某事件发生。
class Program
{
static void Main(string[] args)
{
var t = new Thread(() => Process(10));
t.Start();
Console.WriteLine("Waiting for another thread to complete work");
_workerEvent.WaitOne();
Console.WriteLine("First operation is completed!");
Console.WriteLine("Performing an operation on a main thread");
Thread.Sleep(TimeSpan.FromSeconds(5));
_mainEvent.Set();
Console.WriteLine("Now running the second operation on a second thread");
_workerEvent.WaitOne();
Console.WriteLine("Second operation is completed!");
Console.ReadKey();
}
private static AutoResetEvent _workerEvent = new AutoResetEvent(false);
private static AutoResetEvent _mainEvent = new AutoResetEvent(false);
static void Process(int seconds)
{
Console.WriteLine("Starting a long running work...");
Thread.Sleep(TimeSpan.FromSeconds(seconds));
Console.WriteLine("Work is done!");
_workerEvent.Set();
Console.WriteLine("Waiting for a main thread to complete its work");
_mainEvent.WaitOne();
Console.WriteLine("Starting second operation...");
Thread.Sleep(TimeSpan.FromSeconds(seconds));
Console.WriteLine("Work is done!");
_workerEvent.Set();
}
}
当主程序启动时,定义了两个AutoResetEvent实例。其中一个是从子线程向主线程发信号,另一个实例是从主线程向子线程发信号。我们向AutoResetEvent构造方法传人false,定义了这两个实例的初始状态为unsignaled。这意味着任何线程调用这两个对象中的任何一个的WaitOne方法将会被阻塞,直到我们调用了Set方法。如果初始事件状态为true,那么 AutoResetEvent实例的状态为signaled,如果线程调用WaitOne方法则会被立即处理。然后事件状态自动变为unsignaled,所以需要再对该实例调用一次Set方法,以便让其他的线程对,该实例调用WaitOne方法从而继续执行。
然后我们创建了第二个线程,其会执行第一个操作10秒钟,然后等待从第二个线程发,出的信号。该信号意味着第一个操作已经完成。现在第二个线程在等待主线程的信号。我们对主线程做了一些附加工作,并通过调用mainEvent.Set方法发送了一个信号。然后等待从第二个线程发出的另一个信号。
AutoResetEvent类采用的是内核时间模式,所以等待时间不能太长。使用ManualResetEventslim类更好,因为它使用的是混合模式。
使用ManualResetEventSlim类
本节将描述如何使用ManualResetEventSlim类来在线程间以更灵活的方式传递信号。
class Program
{
static void Main(string[] args)
{
var t1 = new Thread(() => TravelThroughGates("Thread 1", 5));
var t2 = new Thread(() => TravelThroughGates("Thread 2", 6));
var t3 = new Thread(() => TravelThroughGates("Thread 3", 12));
t1.Start();
t2.Start();
t3.Start();
Thread.Sleep(TimeSpan.FromSeconds(6));
Console.WriteLine("The gates are now open!");
_mainEvent.Set();
Thread.Sleep(TimeSpan.FromSeconds(2));
_mainEvent.Reset();
Console.WriteLine("The gates have been closed!");
Thread.Sleep(TimeSpan.FromSeconds(10));
Console.WriteLine("The gates are now open for the second time!");
_mainEvent.Set();
Thread.Sleep(TimeSpan.FromSeconds(2));
Console.WriteLine("The gates have been closed!");
_mainEvent.Reset();
Console.ReadKey();
}
static void TravelThroughGates(string threadName, int seconds)
{
Console.WriteLine("{0} falls to sleep", threadName);
Thread.Sleep(TimeSpan.FromSeconds(seconds));
Console.WriteLine("{0} waits for the gates to open!", threadName);
_mainEvent.Wait();
Console.WriteLine("{0} enters the gates!", threadName);
}
static ManualResetEventSlim _mainEvent = new ManualResetEventSlim(false);
}
当主程序启动时,首先创建了ManualResetEventSlim类的一个实例。然后启动了三个线程,等待事件信号通知它们继续执行。
ManualResetEvnetSlim的整个工作方式有点像人群通过大门。而AutoResetEvent事件像一个旋转门,一次只允许一人通过。ManualResetEventSlim是ManualResetEvent的混合版本,一直保持大门敞开直到手动调用Reset方法。当调用mainEvent.Set时,相当于打开了大门从而允许准备好的线程接收信号并继续工作。然而线程3还处于睡眠 "状态,没有赶上时间。当调用mainEvent.Reset相当于关闭了大门。最后一个线程已经准备好执行,但是不得不等待下一个信号,即要等待好几秒钟。
使用CountdownEvent类
本节将描述如何使用CountdownEvent信号类来等待直到一定数量的操作完成。
class Program
{
static void Main(string[] args)
{
Console.WriteLine("Starting two operations");
var t1 = new Thread(() => PerformOperation("Operation 1 is completed", 4));
var t2 = new Thread(() => PerformOperation("Operation 2 is completed", 8));
t1.Start();
t2.Start();
_countdown.Wait();
Console.WriteLine("Both operations have been completed.");
_countdown.Dispose();
Console.ReadKey();
}
static CountdownEvent _countdown = new CountdownEvent(2);
static void PerformOperation(string message, int seconds)
{
Thread.Sleep(TimeSpan.FromSeconds(seconds));
Console.WriteLine(message);
_countdown.Signal();
}
}
当主程序启动时,创建了一个CountdownEvent实例,在其构造函数中指定了当两个操,作完成时会发出信号。然后我们启动了两个线程,当它们执行完成后会发出信号。一旦第二个线程完成,主线程会从等待CountdownEvent的状态中返回并继续执行。针对需要等待多个异步操作完成的情形,使用该方式是非常便利的。
然而这有一个重大的缺点。如果调用countdown.Signal()没达到指定的次数,那么countdown.Wait()将一直等待。请确保使用CountdownEvent时,所有线程完成后都要调用Signal方法。
使用Barrier类
本节将展示另一种有意思的同步方式,被称为Barrier, Barrier类用于组织多个线程及时, 在某个时刻碰面。其提供了一个回调函数,每次线程调用了SignalAndWait方法后该回调函数会被执行。
class Program
{
static void Main(string[] args)
{
var t1 = new Thread(() => PlayMusic("the guitarist", "play an amazing solo", 5));
var t2 = new Thread(() => PlayMusic("the singer", "sing his song", 2));
t1.Start();
t2.Start();
Console.ReadKey();
}
static Barrier _barrier = new Barrier(2,b => Console.WriteLine("End of phase {0}", b.CurrentPhaseNumber + 1));
static void PlayMusic(string name, string message, int seconds)
{
for (int i = 1; i < 3; i++)
{
Console.WriteLine("----------------------------------------------");
Thread.Sleep(TimeSpan.FromSeconds(seconds));
Console.WriteLine("{0} starts to {1}", name, message);
Thread.Sleep(TimeSpan.FromSeconds(seconds));
Console.WriteLine("{0} finishes to {1}", name, message);
_barrier.SignalAndWait();
}
}
}
我们创建了Barrier类,指定了我们想要同步两个线程。在两个线程中的任何一个调用了barrier.SignalAndWait方法后,会执行一个回调函数来打印出阶段。
每个线程将向Barrier发送两次信号,所以会有两个阶段。每次这两个线程调用Signal AndWait方法时, Barrier将执行回调函数。这在多线程迭代运算中非常有用,可以在每个迭代,结束前执行一些计算。当最后一个线程调用SignalAndWait方法时可以在迭代结束时进行交互。
使用ReaderWriterLockSlim类
本节将描述如何使用ReaderWriterLockSlim来创建一个线程安全的机制,在多线程中对,一个集合进行读写操作。ReaderWriterLockSlim代表了一个管理资源访问的锁,允许多个线程同时读取,以及独占写。
class Program
{
static void Main(string[] args)
{
new Thread(Read){ IsBackground = true }.Start();
new Thread(Read){ IsBackground = true }.Start();
new Thread(Read){ IsBackground = true }.Start();
new Thread(() => Write("Thread 1")){ IsBackground = true }.Start();
new Thread(() => Write("Thread 2")){ IsBackground = true }.Start();
Thread.Sleep(TimeSpan.FromSeconds(30));
Console.ReadKey();
}
static ReaderWriterLockSlim _rw = new ReaderWriterLockSlim();
static Dictionary<int, int> _items = new Dictionary<int, int>();
static void Read()
{
Console.WriteLine("Reading contents of a dictionary");
while (true)
{
try
{
_rw.EnterReadLock();
foreach (var key in _items.Keys)
{
Thread.Sleep(TimeSpan.FromSeconds(0.1));
}
}
finally
{
_rw.ExitReadLock();
}
}
}
static void Write(string threadName)
{
while (true)
{
try
{
int newKey = new Random().Next(250);
_rw.EnterUpgradeableReadLock();
if (!_items.ContainsKey(newKey))
{
try
{
_rw.EnterWriteLock();
_items[newKey] = 1;
Console.WriteLine("New key {0} is added to a dictionary by a {1}", newKey, threadName);
}
finally
{
_rw.ExitWriteLock();
}
}
Thread.Sleep(TimeSpan.FromSeconds(0.1));
}
finally
{
_rw.ExitUpgradeableReadLock();
}
}
}
}
当主程序启动时,同时运行了三个线程来从字典中读取数据,还有另外两个线程向该字典中写入数据。我们使用ReaderWriterLockSlim类来实现线程安全,该类专为这样的场景而设计。
这里使用两种锁:读锁允许多线程读取数据,写锁在被释放前会阻塞了其他线程的所有操作。获取读锁时还有一个有意思的场景,即从集合中读取数据时,根据当前数据而决,定是否获取一个写锁并修改该集合。一旦得到写锁,会阻止阅读者读取数据,从而浪费大量的时间,因此获取写锁后集合会处于阻塞状态。为了最小化阻塞浪费的时间,可以使用EnterUpgradeableReadLock和ExitUpgradeableReadLock方法。先获取读锁后读取数据。如果发现必须修改底层集合,只需使用EnterWriteLock方法升级锁,然后快速执行一次写操作,最后使用ExitWriteLock释放写锁。
在本例中,我们先生成一个随机数。然后获取读锁并检查该数是否存在于字典的键集合中。如果不存在,将读锁更新为写锁然后将该新键加入到字典中。始终使用tyr/finally代码块来确保在捕获锁后一定会释放锁,这是一项好的实践。所有的线程都被创建为后台线程。
主线程在所有后台线程完成后会等待30秒。
使用SpinWait类
本节将描述如何不使用内核模型的方式来使线程等待。另外,我们介绍了SpinWait,它是一个混合同步构造,被设计为使用用户模式等待一段时间,然后切换到内核模式以节省CPU时间。
class Program
{
static void Main(string[] args)
{
var t1 = new Thread(UserModeWait);
var t2 = new Thread(HybridSpinWait);
Console.WriteLine("Running user mode waiting");
t1.Start();
Thread.Sleep(20);
_isCompleted = true;
Thread.Sleep(TimeSpan.FromSeconds(1));
_isCompleted = false;
Console.WriteLine("Running hybrid SpinWait construct waiting");
t2.Start();
Thread.Sleep(5);
_isCompleted = true;
Console.ReadKey();
}
static volatile bool _isCompleted = false;
static void UserModeWait()
{
while (!_isCompleted)
{
Console.Write(".");
}
Console.WriteLine();
Console.WriteLine("Waiting is complete");
}
static void HybridSpinWait()
{
var w = new SpinWait();
while (!_isCompleted)
{
w.SpinOnce();
Console.WriteLine(w.NextSpinWillYield);
}
Console.WriteLine("Waiting is complete");
}
}
当主程序启动时,定义了一个线程,将执行一个无止境的循环,直到20毫秒后主线程,设置_isCompleted变量为true,我们可以试验运行该周期为20-30秒,通过Windows任务管理器测量CPU的负载情况。取决于CPU内核数量,任务管理器将显示一个显著的处理时间。
我们使用volatile关键字来声明isCompleted静态字段。Volatile关键字指出一个字段可能会被同时执行的多个线程修改。声明为volatile的字段不会被编译器和处理器优化为只能被单个线程访问。这确保了该字段总是最新的值。
然后我们使用了SpinWait版本,用于在每个迭代打印一个特殊标志位来显示线程是否切换为阻塞状态。运行该线程5毫秒来查看结果。刚开始, SpinWait尝试使用用户模式,在9 个迭代后,开始切换线程为阻塞状态。如果尝试测量该版本的CPU负载,在Windows任务管理器将不会看到任何CPU的使用。