.NET 4中的多线程编程之二:共享数据(上)
本文侧重实例,关于.NET同步原语的概况介绍,可以参考 Overview of Synchronization Primitives .
在线程间共享数据有可能会导致竞速状态而发生数据不一致的状态, 例如:
namespace TaskParallel { class Account { public int Balance { get; set; } } class Share { static void Main(string[] args) { Account account = new Account { Balance = 0 }; List<Task> tasks = new List<Task>(); for (int i = 0; i < 10; i++) { tasks.Add(Task.Factory.StartNew(() => { for (int j = 0; j < 1000; j++) account.Balance++; })); } Task.WaitAll(tasks.ToArray()); Console.WriteLine(account.Balance); } } }
这段程序中,一共有10个线程,每个线程将一个整型变量自加1000次,期待的结果最终应该是10000,但是运行这段程序的结果每次都不一样而且总比10000小。原因是完成一个变量自加并不是一个原子操作,忽略具体的机器代码不谈,总体上应该是三步,读取当前值,加1,存回计算值.如果线程1读取到了当前值是0,此时被线程2取代而进入等待状态,线程二读取当前值为0,加1,把1存回,线程1接着运行,加1,把1存回。此时Balance的值是1,而已经有线程1和线程2加了2次,数据不一致发生了。下面介绍.Net提供的线程互斥的方法,其实现原理在操作系统原理类的书上有详细介绍,不再赘述。
1. 使用Monitor
为了避免不一致发生,必须保证能够改变共享数据的代码在同一时间只有一个线程在执行,要实现这一点,可以使用C#的lock关键字:
object obj = new object();
for (int i = 0; i < 10; i++) { tasks.Add(Task.Factory.StartNew(() => { for (int j = 0; j < 1000; j++) { lock (obj) { account.Balance++; } } })); }
lock其实是Monitor类的一个包装,要使用更为完整的功能可以使用Monitor类.
2.使用Spin Locking
Spin Locking和Monitor实现的效果相似,但是原理不一样。Spin Locking不阻塞当前线程,而是用一个循环来不断判断是否符合访问条件。当预期阻塞的时间不太长的时候,他比Monitor类高效,但是不适合需要长时间阻塞的情况.
SpinLock locker = new SpinLock(); for (int i = 0; i < 10; i++) {
tasks.Add(Task.Factory.StartNew(() => { for (int j = 0; j < 1000; j++) { bool lockAcquired = false; try { locker.Enter(ref lockAcquired); account.Balance++; } finally { locker.Exit(); } } })); }
3.使用Mutex,Semaphore
Mutex,Semaphore都继承自WaitHandle类,可以实现线程互斥的。WaitHandle是windows的synchronization handles的包装。
先介绍Mutex的例子:
Mutex mutex = new Mutex(); for (int i = 0; i < 10; i++) { tasks.Add(Task.Factory.StartNew(() => { for (int j = 0; j < 1000; j++) { bool lockAcquired = mutex.WaitOne(); account.Balance++;
if(lockAcquired) mutex.ReleaseMutex(); } })); }
WaitHandle的WaitAll方法可以同时获得多个锁,例如在下面的程序中,有两个账户,需要两个锁来保持他们在同一时间只有一个线程访问。其中第三个线程同时访问这两个账户,因此需要同时获得这个两个账户的锁,当第三个线程结束访问的时候,要记得释放两个锁。
using System; using System.Collections.Generic; using System.Linq; using System.Text; using System.Threading; using System.Threading.Tasks; namespace TaskParallel { class Account { public int Balance { get; set; } } class Share { static void Main(string[] args) { Account account1 = new Account { Balance = 0 }; Account account2 = new Account { Balance = 0 }; Mutex mutex1=new Mutex(); Mutex mutex2=new Mutex(); Task t1 = new Task(() => { for (int i = 0; i < 1000; i++) { bool locked = mutex1.WaitOne(); account1.Balance++; if (locked) mutex1.ReleaseMutex(); } }); Task t2 = new Task(() => { for (int i = 0; i < 1000; i++) { bool locked = mutex2.WaitOne(); account2.Balance++; if (locked) mutex2.ReleaseMutex(); } }); Task t3 = new Task(() => { for (int i = 0; i < 1000; i++) { bool locked=WaitHandle.WaitAll(new WaitHandle[] { mutex1, mutex2 }); account1.Balance--; account2.Balance--; if (locked) //release two locks { mutex1.ReleaseMutex(); mutex2.ReleaseMutex(); } } }); t1.Start(); t2.Start(); t3.Start(); Task.WaitAll(t1, t2, t3); Console.Write("Balance1:{0}, Balance2:{1}", account1.Balance, account2.Balance); Console.ReadLine(); } } }
Semaphore提供了控制进入某一代码段的线程的数量的能力,Mutex其实是数量为1的Semaphore。Semaphore类的一个构造函数有两个参数,
public Semaphore( int initialCount, int maximumCount )
MSDN原文解释如下:
This constructor initializes an unnamed semaphore. All threads that use an instance of such a semaphore must have references to the instance.
If initialCount is less than maximumCount, the effect is the same as if the current thread had called WaitOne (maximumCount minus initialCount) times. If you do not want to reserve any entries for the thread that creates the semaphore, use the same number for maximumCount and initialCount.
个人认为这个两个参数的含义设计的有点晦涩,导致这个解释也有点难懂。intialCount是这个信号量当前可以用的数量,maximumCount是最大数量。通常情况下,让intialCount和maximumCount一样就可以了,如果intialCount<maximumCount,则相当于在这个Semaphore初始化的时候,当前线程已经调用了(maximum-initial)次WaitOne,所以当前可用的信号量的值是initialCount, 如果要使用更多的信号量的值,需要在当前线程中调用Release()方法。
例如:
using System; using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using System.Text; namespace TaskParallel { class Sema { public static void Main(string[] args) { Semaphore semaphore = new Semaphore(2, 2); for (int i = 0; i < 5; i++) { Task.Factory.StartNew((obj) => { semaphore.WaitOne(); Console.WriteLine("Thread {0} starts using resource",obj); Thread.Sleep(1000); Console.WriteLine("Thread {0} ends using resource", obj); semaphore.Release(); },i); } Console.ReadLine(); } } }
观察输出结果,可以发现,同一时刻至多只有2个线程在同时执行WaitOne()和Release()之间的代码。
4. 使用Reader-Writer Lock
ReadWriter适合于一部分线程需要独占访问,而一部分只需要读取不需要改变共享变量的值的线程可以并发访问。ReadWriterSlim类提供了实现这种场景的方法。ReadWriterSlim提供了两种锁,一种通过EnterWriterLock获得,一种通过EnterReaderLocker获得。EnterWriterLock之后,该代码被获得锁的线程独占,EnterReaderLocker的时候,如果当前有其他线程获得Reader锁,并不会阻塞当前线程,也就是说可以有多个有Reader锁的线程在运行,但只能有一个Writer锁的线程运行(其他Reader锁的线程也会被阻塞)。
例如:
namespace TaskParallel { class ReadWrite { static void Main(string[] args) { List<int> pool = new List<int>(); ReaderWriterLockSlim rwlock = new ReaderWriterLockSlim(); for (int i = 0; i < 2; i++) { Task.Factory.StartNew((id) => { for (int j = 0; j < 5; j++) { int value=((int)id+1)*j; rwlock.EnterWriteLock(); Console.WriteLine("Thread {0} writing {1}",id,value); pool.Add(value); Thread.Sleep(100); Console.WriteLine("Thread {0} finished writing {1}", id, value); rwlock.ExitWriteLock(); Thread.Sleep(100); } },i); } for (int i = 0; i < 2; i++) { Task.Factory.StartNew((id) => { int count = 10; while (count > 0) { rwlock.EnterReadLock(); Console.WriteLine("Thread {0} begin read", id); for (int j = 0; j < pool.Count; j++) { Console.Write(pool[j] + " "); Thread.Sleep(100); } Console.WriteLine(); Console.WriteLine("Thread {0} finished read", id); rwlock.ExitReadLock(); Thread.Sleep(100); count--; } },i); } Console.ReadLine(); } } } 分析输出结果,可以看到当写进程获得锁的时候,其他的写进程和读进程都会被阻塞,而两个读进程是可以并发执行的。
为了避免死锁,Reader Writer锁是不支持嵌套的。有时候一个线程大多数时候只需要获得Reader锁,但是少部分情况下需要修改下数据,要获得Writer锁,这时候可以使用
EnterUpgradeableReadLock ,拥有这种锁的线程,会阻塞其他请求Writer锁和UpgradableReadLock获得锁,但是不会阻塞ReaderLock。拥有这个锁的线程可以嵌套请求Writer锁。
下面用这个类来实现经典的生产者-消费者问题,为了演示EnterUpgradeableReadLock 不会阻塞 Reader锁的请求线程,还多加了一个线程:
using System; using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using System.Text; namespace TaskParallel { class ReadWrite { static void Main(string[] args) { int[] pool = new int[5]; int index = 0; ReaderWriterLockSlim rwlock = new ReaderWriterLockSlim(); //three producer for (int i = 0; i < 3; i++) { Task.Factory.StartNew((id) => { int count = 0; while (count < 5) { rwlock.EnterWriteLock(); if (index < 5) { pool[index] = ((int)id) * 10 + count; Console.WriteLine("Producer {0} Producing value {1}",id,pool[index]); index++; count++; } rwlock.ExitWriteLock(); } },i); } //three consumers for (int i = 0; i < 3; i++) { Task.Factory.StartNew((id) => { while (true) { Console.WriteLine("Consumer {0} is trying to read", id); rwlock.EnterUpgradeableReadLock(); Console.WriteLine("Consumer {0} Entered ", id); if (index > 0) { Console.WriteLine("Consumer {0} ready to read position {1}", id, index); Thread.Sleep(1000); rwlock.EnterWriteLock(); // upgrade to write lock, need change index index--; int value = pool[index]; rwlock.ExitWriteLock(); Console.WriteLine("Consumer {0} get value {1}", id, value); rwlock.ExitUpgradeableReadLock(); Thread.Sleep(1000); // simulate doing some work with the value } else { rwlock.ExitUpgradeableReadLock(); Thread.Sleep(1000); } } }, i); } //One reader Task.Factory.StartNew(() => { while (true) { rwlock.EnterReadLock(); Console.WriteLine("I'm reading"); Thread.Sleep(100); rwlock.ExitReadLock(); } }); Console.ReadLine(); } } } 分析输出结果,例如如下的片段:
Producer 2 Producing value 22
Consumer 0 Entered
Consumer 0 ready to read position 5
I'm reading
Consumer 1 is trying to read
Consumer 0 get value 22
Producer 2 Producing value 23