CLR via C#, 4th -- 【线程处理】 -- 第29章基元线程同步构造
多个线程同时访问共享数据时,线程同步能防止数据损坏。
不需要线程同步是最理想的情况,因为线程同步锁存在许多问题。
- 第一个问题是它比较繁琐,而且很容易写错。
- 第二个问题在于,它们会损害性能。获取和释放锁是需要时间的,因为要调用一些额外的方法,而且不同的CPU必须进行协调,以决定哪个线程先取得锁。让机器中的CPU以这种方式相互通信,会对性能造成影响。
- 第三个问题在于,它们一次只允许一个线程访问资源。这是锁的全部意义之所在,但也是问题之所在,因为阻塞一个线程会造成更多的线程被创建。
综上所述,线程同步是一件不好的事情,所以在设计自己的应用程序时,应该尽可能地避免进行线程同步。具体就是避免使用像静态字段这样的共享数据。线程用new操作符构造对象时,new操作符会返回对新对象的引用。在这个时刻,只有构造对象的线程才有对它的引用;其他任何线程都不能访问那个对象。如果能避免将这个引用传给可能同时使用对象的另一个线程,就不必同步对该对象的访问。可试着使用值类型,因为它们总是被复制,每个线程操作的都是它自己的副本。
多个线程同时对共享数据进行只读访问是没有任何问题的。String类型便是这样的一个例子:一旦创建好String对象,它就是“不可变"(immutable)的。所以,许多线程能同时访问一个String对象,String对象没有被破坏之虞。
29.1 类库和线程安全
Microsoft的Framework Class Library(FCL)保证所有静态方法都是线程安全的。这意味着假如两个线程同时调用一个静态方法,不会发生数据被破坏的情况。
Console类包含了一个静态字段,类的许多方法都要获取和释放这个字段上的锁,确保一次只有一个线程访问控制台。
要郑重声明的是,使一个方法线程安全,并不是说它一定要在内部获取一个线程同步锁。线程安全的方法意味着在两个线程试图同时访问数据时,数据不会被破坏。
public static Int32 Max(Int32 val1, Int32 val2) { return (val1 < val2) ? val2 : val1; }
这个方法是线程安全的,即使它没有获取任何锁。由于Int32是值类型,所以传给Max的两个Int32值会复制到方法内部。多个线程可以同时调用Max方法,每个线程处理的都是它自己的数据,线程之间互不干扰。
另一方面,FCL不保证实例方法是线程安全的,因为假如全部添加锁定,会造成性能的巨大损失。另外,假如每个实例方法都需要获取和释放一个锁,事实上会造成最终在任何给定的时刻,你的应用程序只有一个线程在运行,这对性能的影响的显而易见的。
建议你自己的类库也遵循FCL的这个模式;也就是说,使自己的所有静态方法都线程安全使所有实例方法都非线程安全。这个模式有一点要注意:如果实例方法的目的是协调线程,则实例方法应该是线程安全的。例如,一个线程可能调用CancellationTokenSource的Cancel方法取消一个操作,另一个线程通过查询对应的CancellationToken的IsCancellationRequested属性,检测到它应该停止正在做的事情。
29.2 基元用户模式和内核模式构造
基元(primitive)是指可以在代码中使用的最简单的构造。
有两种基元构造:用户模式(user-mode)和内核模式(kernel-mode)。
用户模式(user-mode)构造
应尽量使用基元用户模式构造,它们的速度要显著快于内核模式的构造。这是因为它们使用了特殊CPU指令来协调线程。这意味着协调是在硬件中发生的(所以才这么快)。但这也意味着Microsoft Windows操作系统永远检测不到一个线程在基元用户模式的构造上阻塞了。由于在用户模式的基元构造上阻塞的线程池线程永远不认为已阻塞,所以线程池不会创建新线程来替换这种临时阻塞的线程。此外,这些CPU指令只阻塞线程相当短的时间。
但用户模式构造也有一个缺点:只有Windows操作系统内核才能停止一个线程的运行(防止它浪费CPU时间)。在用户模式中运行的线程可能被系统抢占(preempted),但线程会以最快的速度再次调度。所以,想要取得资源但暂时取不到的线程会一直在用户模式中“自旋”。这可能浪费大量CPU时间,而这些CPU时间本可用于执行其他更有用的工作。即便没有其他更有用的工作,更好的做法也是让CPU空闲,这至少能省一点电。
内核模式(kernel-mode)构造
内核模式的构造是由Windows操作系统自身提供的。所以,它们要求在应用程序的线程中调用由操作系统内核实现的函数。将线程从用户模式切换为内核模式(或相反)会招致巨大的性能损失,这正是为什么要避免使用内核模式构造的原因。但它们有一个重要的优点:线程通过内核模式的构造获取其他线程拥有的资源时,Windows会阻塞线程以避免它浪费CPU时间。当资源变得可用时,Windows会恢复线程,允许它访问资源。
“活锁"(livelock)
对于在一个构造上等待的线程,如果拥有这个构造的线程一直不释放它,前者就可能一直阻塞。如果是用户模式的构造,线程将一直在一个CPU上运行,
“死锁”(deadlock)
如果是内核模式的构造,线程将一直阻塞,两种情况都不好。
但在两者之间,死锁总是优于活锁,因为活锁既浪费CPU时间,又浪费内存(线程栈等),而死锁只浪费内存。
混合构造(hybrid construct)
理想中的构造应兼具两者的长处。也就是说,在没有竞争的情况下,这个构造应该快而且不会阻塞(就像用户模式的构造)。但如果存在对构造的竞争,我希望它被操作系统内核阻塞。像这样的构造称为混合构造;
29.3用户模式构造
CLR保证对以下数据类型的变量的读写是原子性的:Boolean,Char,(S)Byte,(U)Int16,(U)Int32,(U)IntPtr,Single以及引用类型。这意味着变量中的所有字节都一次性读取或写入。
internal static class SomeType { public static Int32 x = 0; }
然后,如果一个线程执行这一行代码:
SomeType.x = 0x01234567;
x变量会一次性(原子性)地从0x00000000变成0x01234567,另一个线程不可能看到处于中间状态的值。例如,不可能有别的线程查询SomeType.x并得到值0x01230000,假定上述SomeType类中的x字段是一个Int64,那么当一个线程执行以下代码时:
SomeType.x =0x0123456789abcdef;
另一个线程可能查询x,并得到值0x01234567000000000x000000009abedef值,因为读取和写入操作不是原子性的。这称为一次torn read".
有两种基元用户模式线程同步构造。
易变 构造(volatile construct)
在特定的时间,它在包含一个简单数据类型的变量上执行原子性的读或写操作。
互锁构造(interlocked construct)
在特定的时间,它在包含一个简单数据类型的变量上执行原子性的读和写操作。
所有易变和互锁构造都要求传递对包含简单数据类型的一个变量的引用(内存地址)。
29.3.1 易变构造
C#编译器将你的C#构造转换成中间语言(IL),然后,JIT将1L转换成本机CPU指令,然后由CPU亲自处理这些指令。此外,C#编译器、JIT编译器、甚至CPU本身都可能优化你的代码。
private static void OptimizedAway() { // Constant expression is computed at compile time resulting in zero Int32 value = (1 * 100) (50 * 2); // If value is 0, the loop never executes for (Int32 x = 0; x < value; x++) { // There is no need to compile the code in the loop because it can never execute Console.WriteLine("Jeff"); } }
internal static class StrangeBehavior { // As you'll see later, mark this field as volatile to fix the problem private static Boolean s_stopWorker = false; public static void Main() { Console.WriteLine("Main: letting worker run for 5 seconds"); Thread t = new Thread(Worker); t.Start(); Thread.Sleep(5000); s_stopWorker = true; Console.WriteLine("Main: waiting for worker to stop"); t.Join(); } private static void Worker(Object o) { Int32 x = 0; while (!s_stopWorker) x++; Console.WriteLine("Worker: stopped when x={0}", x); } }
看起来很简单,但要注意,由于会对程序执行各种优化,所以它存在一个潜在的问题。当Worker方法编译时,编译器发现s_stopWorker要么为true,要么为false。它还发现这个值在Worker方法本身中永远都不变化。因此,编译器会生成代码先检查sstopworker.如果s_stopWorker为true,就显示"Worker:stopped when x=0"。如果s_stopWorker为false,编译器就生成代码来进入一个无限循环,并在循环中一直递增x,所以,如你所见,优化导致循环很快就完成,因为对s_stopWorker的检查只有循环前发生一次;不会在循环的每一次迭代时都检查。
要想实际体验这一切,请将上述代码放到一个.cs文件中,再用C编译器(csc.exe)的/platform:x86和/optimize+开关来编译。x86 JIT编译器比x64编译器更成熟,所以它在执行优化的时候更大胆。其他JIT编译器不执行这个特定的优化,所以程序会像预期的那样正常运行到结束。除此之外,要看到上面这个程序进入死循环,一定不能在调试器中运行它,因为调试器会造成JIT编译器生成未优化的代码(目的是方便你进行单步调试)。
internal sealed class ThreadsSharingData { private Int32 m_flag = 0; private Int32 m_value = 0; // This method is executed by one thread public void Thread1() { // Note: These could execute in reverse order m_value = 5; m_flag = 1; } // This method is executed by another thread public void Thread2() { // Note: m_value could be read before m_flag if (m_flag == 1) Console.WriteLine(m_value); } }
上述代码的问题在于,编译器和CPU在解释代码的时候,可能反转Thread1方法中的两行代码。毕竟,反转两行代码不会改变方法的意图。方法需要在m_value中存储5,在m_flag中存储1。从单线程应用程序的角度说,这两行代码的执行顺序无关紧要。如果这两行代码真的按相反顺序执行,执行Thread2方法的另一个线程可能看到m_flag是1,并显示0。
下面从另一个角度研究上述代码。假定Thread1方法中的代码按照程序顺序(就是编码顺序)执行。编译Thread2方法中的代码时,编译器必须生成代码将m_flag和m_value从RAM读入CPU寄存器。RAM可能先传递m_value的值,它包含0值。然后,Thread1方法可能执行,将m_value更改为5,将m_flag更改为1。但Thread2的CPU寄存器没有看到m_value已被另一个线程更改为5。然后,m_flag的值从RAM读入CPU寄存器。由于m_flag已变成1,造成Thread2同样显示0.
静态System.Threading.Volatile类提供了两个静态方法
public static class Volatile { public static void Write(ref Int32 location, Int32 value); public static Int32 Read(ref Int32 location); }
Volatile.Write方法强迫location中的值在调用时写入。此外,按照编码顺序,之前的加载和存储操作必须在调用Volatile.Write之前发生。
Volatile.Read方法强迫location中的值在调用时读取。此外,按照编码顺序,之后的加载和存储操作必须在调用Volatile.Read之后发生。
当线程通过共享内存相互通信时,调用Volatile.Write来写入最后一个值,调用Volatile.Read来读取第一个值。
internal sealed class ThreadsSharingData { private Int32 m_flag = 0; private Int32 m_value = 0; // This method is executed by one thread public void Thread1() { // Note: 5 must be written to m_value before 1 is written to m_flag m_value = 5; Volatile.Write(ref m_flag, 1); } // This method is executed by another thread public void Thread2() { // Note: m_value must be read after m_flag is read if (Volatile.Read(ref m_flag) == 1) Console.WriteLine(m_value); } }
C#对易变字段的支持
为了简化编程,C#编译器提供了volatile关键字,它可应用于以下任何类型的静态或实例字段:Boolean,(S)Byte,(U)Int16,(U)Int32,(U)IntPtr,Single和Char。还可将volatile关键字应用于引用类型的字段,以及基础类型为(S)Byte,(U)Int16或(U)Int32的任何枚举字段。JT编译器确保对易变字段的所有访问都是以易变读取或写入的方式执行,不必显式调用Volatile的静态Read或Write方法。另外,volatile关键字告诉C#和JT编译器不将字段缓存到CPU的寄存器中,确保字段的所有读写操作都在RAM中进行。
internal sealed class ThreadsSharingData { private volatile Int32 m_flag = 0; private Int32 m_value = 0; // This method is executed by one thread public void Thread1() { // Note: 5 must be written to m_value before 1 is written to m_flag m_value = 5; m_flag = 1; } // This method is executed by another thread public void Thread2() { // Note: m_value must be read after m_flag is read if (m_flag == 1) Console.WriteLine(m_value); } }
通常,要倍增一个整数,只需将它的所有位都左移1位,许多编译器都能检测到上述代码的意图,并执行这个优化。如果m-amount是volatile字段,就不允许执行这个优化。编译器必须生成代码将m_amount读入一个寄存器,再把它读入另一个寄存器,将两个寄存器加到一起,再将结果写回m-amount字段。未优化的代码肯定会更大、更慢;如果它包含在一个循环中,更会成为一个大大的杯具。
m_amount = m_amount + m_amount; // Assume m_amount is a volatile field defined in a class
另外,C#不支持以传引用的方式将volatile字段传给方法。
Boolean success = Int32.TryParse("123", out m_amount); // The preceding line causes the C# compiler to generate a warning: // CS0420: a reference to a volatile field will not be treated as volatile
29.3.2 互锁构造
System.Threading.Interlocked类提供的方法。Interlocked类中的每个方法都执行一次原子读取以及写入操作。此外,Interlocked的所有方法都建立了完整的内存栅栏(memory fence)。换言之,调用某个Interlocked方法之前的任何变量写入都在这个Interlocked方法调用之前执行;而这个调用之后的任何变量读取都在这个调用之后读取。
public static class Interlocked { // return (++location) public static Int32 Increment(ref Int32 location); // return (location) public static Int32 Decrement(ref Int32 location); // return (location += value) // Note: value can be a negative number allowing subtraction public static Int32 Add(ref Int32 location, Int32 value); // Int32 old = location; location = value; return old; public static Int32 Exchange(ref Int32 location, Int32 value); // Int32 old = location; // if (location == comparand) location = value; // return old; public static Int32 CompareExchange(ref Int32 location, Int32 value, Int32 comparand); ... }
此外,Interlocked类提供了Exchange和CompareExchange方法,它们能接受Object,IntPtr,Single和Double等类型的参数。
internal sealed class MultiWebRequests { // This helper class coordinates all the asynchronous operations private AsyncCoordinator m_ac = new AsyncCoordinator(); // Set of web servers we want to query & their responses (Exception or Int32) // NOTE: Even though multiple could access this dictionary simultaneously, // there is no need to synchronize access to it because the keys are // readonly after construction private Dictionary<String, Object> m_servers = new Dictionary<String, Object> { { "http://Wintellect.com/", null }, { "http://Microsoft.com/", null }, { "http://1.1.1.1/", null } }; public MultiWebRequests(Int32 timeout = Timeout.Infinite) { // Asynchronously initiate all the requests all at once var httpClient = new HttpClient(); foreach (var server in m_servers.Keys) { m_ac.AboutToBegin(1); httpClient.GetByteArrayAsync(server) .ContinueWith(task => ComputeResult(server, task)); } // Tell AsyncCoordinator that all operations have been initiated and to call // AllDone when all operations complete, Cancel is called, or the timeout occurs m_ac.AllBegun(AllDone, timeout); } private void ComputeResult(String server, Task<Byte[]> task) { Object result; if (task.Exception != null) { result = task.Exception.InnerException; } else { // Process I/O completion here on thread pool thread(s) // Put your own computeintensive algorithm here... result = task.Result.Length; // This example just returns the length } // Save result (exception/sum) and indicate that 1 operation completed m_servers[server] = result; m_ac.JustEnded(); } // Calling this method indicates that the results don't matter anymore public void Cancel() { m_ac.Cancel(); } // This method is called after all web servers respond, // Cancel is called, or the timeout occurs private void AllDone(CoordinationStatus status) { switch (status) { case CoordinationStatus.Cancel: Console.WriteLine("Operation canceled."); break; case CoordinationStatus.Timeout: Console.WriteLine("Operation timed out."); break; case CoordinationStatus.AllDone: Console.WriteLine("Operation completed; results below:"); foreach (var server in m_servers) { Console.Write("{0} ", server.Key); Object result = server.Value; if (result is Exception) { Console.WriteLine("failed due to {0}.", result.GetType().Name); } else { Console.WriteLine("returned {0:N0} bytes.", result); } } break; } } }
首先调用AsyneCoordinator的AboutToBegin方法,向它传递要发出的请求数量。对Web服务器的所有请求都发出之后,将调用AsyncCoordinator的AllBegun方法,向它传递要在所有操作完成后执行的方法(AlIDone)以及一个超时值。存储好每个结果之后,会调用AsyncCoordinator的JustEnded方法,使AsyncCoordinator对象知道一个操作已经完成。
所有操作完成后,AsyncCoordinator会调用AllDone方法处理来自所有web服务器的结果。执行AllDone方法的线程就是获取最后一个Web服务器响应的那个线程池线程。但如果发生超时或取消,调用AllDone的线程就是向AsyncCoordinator通知超时的那个线程池线程,或者是调用Cancel方法的那个线程。也有可能AllDone由发出web服务器请求的那个线程调用——如果最后一个请求在调用AllBegun之前完成。
注意,这里存在竞态条件,因为以下事情可能恰好同时发生:所有Web服务器请求完成、调用AllBegun、发生超时以及调用Cancel。这时,AsyncCoordinator会选择1个赢家和3个输家,确保AllDone方法不被多次调用。赢家是通过传给AllDone的status实参来识别的。
internal enum CoordinationStatus { AllDone, Timeout, Cancel };
AsyncCoordinator类封装了所有线程协调(合作)逻辑。它用Interlocked提供的方法来操作一切,确保代码以极快的速度运行,同时没有线程会被阻塞。
internal sealed class AsyncCoordinator { private Int32 m_opCount = 1; // Decremented when AllBegun calls JustEnded private Int32 m_statusReported = 0; // 0=false, 1=true private Action<CoordinationStatus> m_callback; private Timer m_timer; // This method MUST be called BEFORE initiating an operation public void AboutToBegin(Int32 opsToAdd = 1) { Interlocked.Add(ref m_opCount, opsToAdd); } // This method MUST be called AFTER an operation’s result has been processed public void JustEnded() { if (Interlocked.Decrement(ref m_opCount) == 0) ReportStatus(CoordinationStatus.AllDone); } // This method MUST be called AFTER initiating ALL operations public void AllBegun(Action<CoordinationStatus> callback, Int32 timeout = Timeout.Infinite) { m_callback = callback; if (timeout != Timeout.Infinite) m_timer = new Timer(TimeExpired, null, timeout, Timeout.Infinite); JustEnded(); } private void TimeExpired(Object o) { ReportStatus(CoordinationStatus.Timeout); } public void Cancel() { ReportStatus(CoordinationStatus.Cancel); } private void ReportStatus(CoordinationStatus status) { // If status has never been reported, report it; else ignore it if (Interlocked.Exchange(ref m_statusReported, 1) == 0) m_callback(status); } }
这个类最重要的字段就是m_opCount字段,用于跟踪仍在进行的异步操作的数量。每个异步操作开始前都会调用AboutToBegin。该方法调用Interlocked.Add,以原子方式将传给它的数字加到m_opCount字段上。m_opCount上的加法运算必须以原子方式进行,因为随着更多的操作开始,可能开始在线程池线程上处理Web服务器的响应。处理好web服务器的响应后会调用JustEnded。该方法调用Interlocked.Decrement,以原子方式从m_opCount上减1,无论哪一个线程恰好将m_opCount设为0,都由它调用ReportStatus.
m_opCount字段初始化为1(而非0),这一点很重要。执行构造器方法的线程在发出Web服务器请求期间,由于m_opCount字段为1,所以能保证AllDone不会被调用。构造器调用AllBegun之前,m_opCount永远不可能变成0,构造器调用AllBegun时,AllBegun内部调用JustEnded来递减m_opCount,所以事实上撤消(undo)了把它初始化成1的效果。现在m_opCount能变成0了,但只能是在发起了所有Web服务器请求之后。
ReportStatus方法对全部操作结束、发生超时和调用Cancel时可能发生的竞态条件进行仲裁ReportStatus必须确保其中只有一个条件胜出,确保mcallback方法只被调用一次。为了仲裁赢家,要调用Interlocked.Exchange方法,向它传递对mstatusReported字段的引用。这个字段实际是作为一个Boolean变量使用的;但不能真的把它写成一个Boolean变量,因为没有任何Interlocked方法能接受Boolean变量。因此,我用一个Int32变量来代替,0意味着false,1意味着true.
在ReportStatus内部,Interlocked.Exchange调用会将m-statusReported更改为1,但只有做这个事情的第一个线程才会看到Interlocked.Exchange返回0,只有这个线程才能调用回调方法。调用Interlocked.Exchange的其他任何线程都会得到返回值1,相当于告诉这些线程:回调方法已被调用,你不要再调用了。
29.3.3 实现简单的自旋锁
Interlocked的方法很好用,但主要用于操作Int32值。如果需要原子性地操作类对象中的一组字段,又该怎么办呢?在这种情况下,需要采取一个办法阻止所有线程,只允许其中一个进入对字段进行操作的代码区域。可以使用Interlocked的方法构造一个线程同步块:
internal struct SimpleSpinLock { private Int32 m_ResourceInUse; // 0=false (default), 1=true public void Enter() { while (true) { // Always set resource to inuse // When this thread changes it from not in use, return //从0变成1才返回(结束自旋),从1变成1不返回(继续自旋). if (Interlocked.Exchange(ref m_ResourceInUse, 1) == 0) return; // Black magic goes here... } } public void Leave() { // Set resource to not in use Volatile.Write(ref m_ResourceInUse, 0); } }
public sealed class SomeResource { private SimpleSpinLock m_sl = new SimpleSpinLock(); public void AccessResource() { m_sl.Enter(); // Only one thread at a time can get in here to access the resource... m_sl.Leave(); } }
SimpleSpinLock的实现很简单。如果两个线程同时调用Enter,那么Interlocked.Exchange会确保一个线程将m-resourcelnUse从0变成1,并发现mresourcelnUse为0",然后这个线程从Enter返回,使它能继续执行AccessResource方法中的代码。另一个线程会将m_resourcelnUse从1变成1,由于不是从0变成1,所以会不停地调用Exchange进行“自旋”,直到第一个线程调用Leave。
这就是线程同步锁的一个简单实现。这种锁最大的问题在于,在存在对锁的竞争的前提下,会造成线程“自旋”。这个“自旋”会浪费宝贵的CPU时间,阻止CPU做其他更有用的工作。因此,自旋锁只应该用于保护那些会执行得非常快的代码区域。
FCL还包含一个System.Threading.SpinLock结构,它和前面展示的SimpleSpinLock类相似,只是使用了SpinWait结构来增强性能。SpinLock结构还提供了超时支持。
在线程处理中引入延迟
SpinWait结构内部调用Thread的静态Sleep,Yield 和 SpinWait方法。Sleep方法导致线程在指定时间内挂起。调用Sleep允许线程自愿放弃它的时间片的剩余部分。
可以调用Sleep,并为millisecondsTimeout参数传递System.Threading.Timeout.Infinito中的值(定义为-1),这告诉系统永远不调度线程,但这样做没什么意义。更好的做法是让线程退出,回收它的栈和内核对象。可以向Sleep传递0,告诉系统调用线程放弃了它当前时间片的剩余部分,强迫系统调度另一个线程。
线程可要求Windows在当前CPU上调度另一个线程,这是通过Thread的Yield方法来实现的:
public static Boolean Yield();
如果Windows发现有另一个线程准备好在当前处理器上运行,Yield就会返回true,调用Yield的线程会提前结束它的时间片",所选的线程得以运行一个时间片。然后,调用Yield的线程被再次调度,开始用一个全新的时间片运行。如果Windows发现没有其他线程准备在当前处理器上运行,Yield就会返回false,调用Yield的线程继续运行它的时间片。
调用Yield的效果介于调用Thread.Sleep(0)和Thread.Sleep(1)之间.Thread.Sleep(0)不允许较低优先级的线程运行,而Thread.Sleep(1)总是强迫进行上下文切换,而由于内部系统计时器的解析度的问题,Windows总是强迫线程睡眠超过1毫秒的时间。
事实上,超线程CPU一次只允许一个线程运行。所以,在这些CPU上执行“自旋”循环时,需要强迫当前线程暂停,使CPU有机会切换到另一个线程并允许它运行。
线程可调用Thread的SpinWait方法强迫它自身暂停,允许超线程CPU切换到另一个线程:
public static void SpinWait(Int32 iterations);
调用SpinWait方法实际会执行一个特殊的CPU指令;它不告诉Windows做任何事情(因为Windows已经认为它在CPU上调度了两个线程),在非超线程CPU上,这个特殊CPU指令会被忽略。
29.3.4 Interlocked Anything模式
Interlocked.CompareExchange提供了其他重载版本,能操作Int64,Single,Double,Object和泛型引用类型,所以该模式适合所有这些类型。
public static Int32 Maximum(ref Int32 target, Int32 value) { Int32 currentVal = target, startVal, desiredVal; // Don't access target in the loop except in an attempt // to change it because another thread may be touching it do { // Record this iteration's starting value startVal = currentVal; // Calculate the desired value in terms of startVal and value desiredVal = Math.Max(startVal, value); // NOTE: the thread could be preempted here! // if (target == startVal) target = desiredVal // Value prior to potential change is returned currentVal = Interlocked.CompareExchange(ref target, desiredVal, startVal); // If the starting value changed during this iteration, repeat } while (startVal != currentVal); // Return the maximum value when this thread tried to set it return desiredVal; }
进入方法后,currentVal被初始化为方法开始执行时的target值。然后,在循环内部,startVal被初始化为同一个值。可用startVal执行你希望的任何操作。这个操作可以非常复杂,可以包含成千上万行代码。但最终要得到一个结果,并将结果放到desiredVal中。本例判断startVal和value哪个最大。
现在,当这个操作进行时,其他线程可能更改target。虽然机率很小,但仍是有可能发生的。如果真的发生,desiredVal的值就是基于存储在startVal中的旧值而获得的,而非基于target的新值。这时就不应更改target。我们用Interlocked.CompareExchange方法确保在没有其他线程更改target的前提下将target的值更改为desiredVal.该方法验证target值和startVal值匹配(startVal代表操作开始前的target值)。如果target值没有改变,CompareExchange就把它更改为desiredVal中的新值。如果target的值被(另一个线程)改变了,CompareExchange就不更改target CompareExchange在调用的同时会返回target中的值,我将该值存储到currentVal中。
然后比较startVal和currentVal,两个值相等,表明没有其他线程更改target,target现在包含desiredVal中的值,while循环不再继续,方法返回。相反,如果startVal不等于currentVal,表明有其他线程更改了target,target没有变成desiredVal中的值,所以while循环继续下一次迭代,再次尝试相同的操作,这一次用currentVal中的新值来反映其他线程的更改。
delegate Int32 Morpher<TResult, TArgument>(Int32 startValue, TArgument argument, out TResult morphResult); static TResult Morph<TResult, TArgument>(ref Int32 target, TArgument argument, Morpher<TResult, TArgument> morpher) { TResult morphResult; Int32 currentVal = target, startVal, desiredVal; do { startVal = currentVal; desiredVal = morpher(startVal, argument, out morphResult); currentVal = Interlocked.CompareExchange(ref target, desiredVal, startVal); } while (startVal != currentVal); return morphResult; }
乐观锁
乐观并发控制(又名“乐观锁”,Optimistic Concurrency Control,缩写"OCC")是一种并发控制的方法。它假设多用户并发的事务在处理时不会彼此互相影响,各事务能够在不产生锁的情况下处理各自影响的那部分数据。在提交数据更新之前,每个事务会先检查在该事务读取数据后,有没有其他事务又修改了该数据。如果其他事务有更新的话,正在提交的事务会进行回滚。乐观事务控制最早是由孔祥重(H.T.Kung)教授提出。乐观并发控制多数用于数据争用不大、冲突较少的环境中,这种环境中,偶尔回滚事务的成本会低于读取数据时锁定数据的成本,因此可以获得比其他并发控制方法更高的吞吐量。
29.4 内核模式构造
内核模式的构造比用户模式的构造慢得多,一个原因是它们要求Windows操作系统自身的配合,另一个原因是在内核对象上调用的每个方法都造成调用线程从托管代码转换为本机(native)用户模式代码,再转换为本机(native)内核模式代码。然后,还要朝相反的方向一路返回。这些转换需要大量CPU时间;经常执行会对应用程序的总体性能造成负面影响。
内核模式的构造具备基元用户模式构造所不具备的优点。
- 内核模式的构造检测到在一个资源上的竞争时,Windows会阻塞输掉的线程,使它不占着一个CPU“自旋”(spinning),无谓地浪费处理器资源。
- 内核模式的构造可实现本机(native)和托管(managed)线程相互之间的同步。
- 内核模式的构造可同步在同一台机器的不同进程中运行的线程。
- 内核模式的构造可应用安全性设置,防止未经授权的帐户访问它们。
- 线程可一直阻塞,直到集合中的所有内核模式构造都可用,或直到集合中的任何内核模式构造可用。
- 在内核模式的构造上阻塞的线程可指定超时值;指定时间内访问不到希望的资源,线程就可以解除阻塞并执行其他任务。
事件和信号量(semaphores)是两种基元内核模式线程同步构造。至于其他内核模式构造,比如互斥体(mutex),则是在这两个基元构造上构建的。
System.Threading命名空间提供了一个名为WaitHandle抽象基类。waitHandle类是一个很简单的类,它唯一的作用就是包装一个Windows内核对象句柄。FCL提供了几个从WaitHandle派生的类。所有类都在System.Threading命名空间中定义。
WaitHandle
EventWaitHandle
AutoResetEvent
ManualResetEvent
Semaphore
Mutex
WaitHandle基类内部有一个SafewaitHandle字段,它容纳了一个Win32内核对象句柄。这个字段是在构造一个具体的WaitHandle派生类时初始化的。除此之外,WaitHandle类公开了由所有派生类继承的方法。在一个内核模式的构造上调用的每个方法都代表一个完整的内存栅栏。
栅栏,是表明调用这个方法之前的任何变量写入都必须在这个方法调用之前发生;而这个调用之后的任何变量读取都必须在这个调用之后发生。
public abstract class WaitHandle : MarshalByRefObject, IDisposable { // WaitOne internally calls the Win32 WaitForSingleObjectEx function. public virtual Boolean WaitOne(); public virtual Boolean WaitOne(Int32 millisecondsTimeout); public virtual Boolean WaitOne(TimeSpan timeout); // WaitAll internally calls the Win32 WaitForMultipleObjectsEx function public static Boolean WaitAll(WaitHandle[] waitHandles); public static Boolean WaitAll(WaitHandle[] waitHandles, Int32 millisecondsTimeout); public static Boolean WaitAll(WaitHandle[] waitHandles, TimeSpan timeout); // WaitAny internally calls the Win32 WaitForMultipleObjectsEx function public static Int32 WaitAny(WaitHandle[] waitHandles); public static Int32 WaitAny(WaitHandle[] waitHandles, Int32 millisecondsTimeout); public static Int32 WaitAny(WaitHandle[] waitHandles, TimeSpan timeout); public const Int32 WaitTimeout = 258; // Returned from WaitAny if a timeout occurs // Dispose internally calls the Win32 CloseHandle function – DON’T CALL THIS. public void Dispose(); }
这些方法有几点需要注意
- 以调用WaitHandle的WaitOne方法让调用线程等待底层内核对象收到信号。这个方法在内部调用win32 WaitForSingleObjectEx函数。如果对象收到信号,返回的Boolean是true:超时就返回false
- 可以调用WaitHandle的静态WaitAll方法,让调用线程等待WaitHandlel中指定的所有内核对象都收到信号。如果所有对象都收到信号,返回的Boolean是true;超时则返回false。这个方法在内部调用Win32 WaitForMultipleObjectsEx函数,为bWaitAll参数传递TRUE.
- 可以调用WaitHandle的静态WaitAny方法让调用线程等待waitHandlell中指定的任何内核对象收到信号。返回的Int32是与收到信号的内核对象对应的数组元素索引如果在等待期间没有对象收到信号,则返回WaitHandle.WaitTimeout,这个方法在内部调用Win32 WaitForMultipleObjectsEx函数,为bWaitAll参数传递FALSE.
- 在传给WaitAny和WaitAll方法的数组中,包含的元素数不能超过64个,否则方法会抛出一个System.NotSupportedException.
- 可以调用WaitHandle的Dispose方法来关闭底层内核对象句柄。这个方法在内部调用Win32 CloseHandle函数。只有确定没有别的线程要使用内核对象才能显式调用Dispose。你需要写代码并进行测试,这是一个巨大的负担。所以我强烈反对显式调用Dispose:相反,让垃圾回收器(GC)去完成清理工作。GC知道什么时候没有线程使用对象,会自动进行清理。从某个角度看,GC是在帮你进行线程同步!
不接受超时参数的那些版本的WaitOne和WaitAll方法应返回void而不是Boolean,原因是隐含的超时时间是无限长(System.Threading.Timeout.Infinite),所以它们只会返回true.调用任何这些方法时都不需要检查返回值。
如前所述,AutoResetEvent,ManualResetEvent,Semaphore和Mutex类都派生自WaitHandle,因此它们继承了WaitHandle的方法和行为。
首先,所有这些类的构造器都在内部调用Win32 CreateEvent(为bManualReset参数传递FALSE或TRUE),CreateSemaphore或CreateMutex函数。从所有这些调用返回的句柄值都保存在WaitHandle基类内部定义的一个私有SafeWaitHandle字段中。
其次,EventWaitHandle,Semaphore和Mutex类都提供了静态OpenExisting方法,它们在内部调用Win32 OpenEvent,OpenSemaphore或OpenMutex函数,并传递一个String实参(标识现有的一个具名内核对象)。所有函数返回的句柄值都保存到从OpenExisting方法返回的一个新构造的对象中。如果指定名称的内核对象不存在,就抛出一个WaitHandleCannotBeOpenedException异常。
using System; using System.Threading; public static class Program { public static void Main() { Boolean createdNew; // Try to create a kernel object with the specified name using (new Semaphore(0, 1, "SomeUniqueStringIdentifyingMyApp", out createdNew)) { if (createdNew) { // This thread created the kernel object so no other instance of this // application must be running. Run the rest of the application here... } else { // This thread opened an existing kernel object with the same string name; // another instance of this application must be running now. // There is nothing to do in here, let's just return from Main to terminate // this second instance of the application. } } } }
利用了在创建任何种类的内核对象时由Windows内核提供的一些线程同步行为。每个进程都有自己的线程,两个线程都尝试创建具有相同字符串名称(本例是"SomeUniqueStringldentifyingMyApp")的一个Semaphore.Windows内核确保只有一个线程实际地创建具有指定名称的内核对象;创建对象的线程会将它的createdNew变量设为true.
对于第二个线程,Windows发现具有指定名称的内核对象已经存在了。因此,不允许第二个线程创建另一个同名的内核对象。不过,如果这个线程继续运行的话,它能访问和第-个进程的线程所访问的一样的内核对象。不同进程中的线程就是这样通过内核对象来通信的。但在本例中,第二个进程的线程看到它的createdNew变量设为false,所以知道有进程的另一个实例正在运行,所以进程的第二个实例立即退出。
29.4.1 Event构造
事件(event)其实只是由内核维护的Boolean变量。
事件为false,在事件上等待的线程就阻塞:事件为true,就解除阻塞。有两种事件,即自动重置事件和手动重置事件。
当一个自动重置事件为true时,它只唤醒一个阻塞的线程,因为在解除第一个线程的阻塞后,内核将事件自动重置回false,造成其余线程继续阻塞。
而当一个手动重置事件为true时,它解除正在等待它的所有线程的阻塞,因为内核不将事件自动重置回false:现在,你的代码必须将事件手动重置回false。
public class EventWaitHandle : WaitHandle { public Boolean Set(); // Sets Boolean to true; always returns true public Boolean Reset(); // Sets Boolean to false; always returns true } public sealed class AutoResetEvent : EventWaitHandle { public AutoResetEvent(Boolean initialState); } public sealed class ManualResetEvent : EventWaitHandle { public ManualResetEvent(Boolean initialState); }
可用自动重置事件轻松创建线程同步锁,它的行为和前面展示的SimpleSpinLock类相似:
internal sealed class SimpleWaitLock : IDisposable { private readonly AutoResetEvent m_available; public SimpleWaitLock() { m_available = new AutoResetEvent(true); // Initially free } public void Enter() { // Block in kernel until resource available m_available.WaitOne(); } public void Leave() { // Let another thread access the resource m_available.Set(); } public void Dispose() { m_available.Dispose(); } }
锁上面没有竞争的时候,SimplewaitLock比SimpleSpinLock慢得多,因为对SimplewaitLock的Enter和Leave方法的每一个调用都强迫调用线程从托管代码转换为内核代码,再转换回来-这是不好的地方。但在存在竞争的时候,输掉的线程会被内核阻塞,不会在那里“自旋",从而浪费CPU时间-这是好的地方。
public static void Main() { Int32 x = 0; const Int32 iterations = 10000000; // 10 million // How long does it take to increment x 10 million times? Stopwatch sw = Stopwatch.StartNew(); for (Int32 i = 0; i < iterations; i++) { x++; } Console.WriteLine("Incrementing x: {0:N0}", sw.ElapsedMilliseconds); // How long does it take to increment x 10 million times // adding the overhead of calling a method that does nothing? sw.Restart(); for (Int32 i = 0; i < iterations; i++) { M(); x++; M(); } Console.WriteLine("Incrementing x in M: {0:N0}", sw.ElapsedMilliseconds); // How long does it take to increment x 10 million times // adding the overhead of calling an uncontended SimpleSpinLock? SpinLock sl = new SpinLock(false); sw.Restart(); for (Int32 i = 0; i < iterations; i++) { Boolean taken = false; sl.Enter(ref taken); x++; sl.Exit(); } Console.WriteLine("Incrementing x in SpinLock: {0:N0}", sw.ElapsedMilliseconds); // How long does it take to increment x 10 million times // adding the overhead of calling an uncontended SimpleWaitLock? using (SimpleWaitLock swl = new SimpleWaitLock()) { sw.Restart(); for (Int32 i = 0; i < iterations; i++) { swl.Enter(); x++; swl.Leave(); } Console.WriteLine("Incrementing x in SimpleWaitLock: {0:N0}", sw.ElapsedMilliseconds); } } [MethodImpl(MethodImplOptions.NoInlining)] private static void M() { /* This method does nothing but return */ }
由此可以看到,单纯递增x只需8毫秒。递增前后多调用一个方法,就要多花约9倍的时间。然后,在用户模式的构造中执行递增,代码变慢了21倍(164/8)。最后,如果使用内核模式的构造,程序更是慢得可怕,慢了大约1107倍(8864/)!所以,线程同步能避免就尽量避免。如果一定要进行线程同步,就尽量使用用户模式的构造。内核模式的构造要尽量避免。
29.4.2 Semaphore构造
信号量(semaphore)其实就是由内核维护的Int32变量。信号量为0时,在信号量上等待的线程会阻塞;信号量大于0时解除阻塞。在信号量上等待的线程解除阻塞时,内核自动从信号量的计数中减1,信号量还关联了一个最大Int32值,当前计数绝不允许超过最大计数。
public sealed class Semaphore : WaitHandle { public Semaphore(Int32 initialCount, Int32 maximumCount); public Int32 Release(); // Calls Release(1); returns previous count public Int32 Release(Int32 releaseCount); // Returns previous count }
下面总结一下这三种内核模式基元的行为。
- 多个线程在一个自动重置事件上等待时,设置事件只导致一个线程被解除阻塞。
- 多个线程在一个手动重置事件上等待时,设置事件导致所有线程被解除阻塞。
- 多个线程在一个信号量上等待时,释放信号量导致releaseCount个线程被解除阻塞(releaseCount是传给Semaphore的Release方法的实参)。
因此,自动重置事件在行为上和最大计数为1的信号量非常相似。两者的区别在于,可以在一个自动重置事件上连续多次调用Set,同时仍然只有一个线程解除阻塞。相反,在一个信号量上连续多次调用Release,会使它的内部计数一直递增,这可能解除大量线程的阻塞。顺便说一句,如果在一个信号量上多次调用Release,会导致它的计数超过最大计数,这时Release会抛出一个SemaphoreFullException.
public sealed class SimpleWaitLock : IDisposable { private readonly Semaphore m_available; public SimpleWaitLock(Int32 maxConcurrent) { m_available = new Semaphore(maxConcurrent, maxConcurrent); } public void Enter() { // Block in kernel until resource available m_available.WaitOne(); } public void Leave() { // Let another thread access the resource m_available.Release(1); } public void Dispose() { m_available.Close(); } }
29.4.3 Mutex构造
互斥体(mutex)代表一个互斥的锁。它的工作方式和AutoResetEvent(或者计数为1的Semaphore相似,三者都是一次只释放一个正在等待的线程。
public sealed class Mutex : WaitHandle { public Mutex(); public void ReleaseMutex(); }
首先,Mutex对象会查询调用线程的Int32 ID,记录是哪个线程获得了它。一个线程调用ReleaseMutex时,Mutex确保调用线程就是获取Mutex的那个线程。如若不然,Mutex对象的状态就不会改变,而ReleaseMutex会抛出一个System.ApplicationException。
另外,拥有Mutex的线程因为任何原因而终止,在Mutex上等待的某个线程会因为抛出System.Threading.AbandonedMutexException异常而被唤醒。该异常通常会成为未处理的异常,从而终止整个进程。这是好事,因为线程在获取了一个Mutex之后,可能在更新完Mutex所保护的数据之前终止。如果其他线程捕捉了AbandonedMutexException,就可能试图访问损坏的数据,造成无法预料的结果和安全隐患。
其次,Mutex对象维护着一个递归计数(recursion count),指出拥有该Mutex的线程拥有了它多少次。如果一个线程当前拥有一个Mutex,而后该线程再次在Mutex上等待,计数就会递增,这个线程允许继续运行。线程调用ReleaseMutex将导致计数递减。只有计数变成0,另一个线程才能成为该Mutex的所有者。
通常,当一个方法获取了一个锁,然后调用也需要锁的另一个方法,就需要一个递归锁。
internal class SomeClass : IDisposable { private readonly Mutex m_lock = new Mutex(); public void Method1() { m_lock.WaitOne(); // Do whatever... Method2(); // Method2 recursively acquires the lock m_lock.ReleaseMutex(); } public void Method2() { m_lock.WaitOne(); // Do whatever... m_lock.ReleaseMutex(); } public void Dispose() { m_lock.Dispose(); } }
在上述代码中,使用一个SomeClass对象的代码可以调用Method1,它获取Mutex,执行一些线程安全的操作,然后调用Method2,它也执行一些线程安全的操作。由于Mutex对象支持递归,所以线程会获取两次锁,然后释放它两次。在此之后,另一个线程才能拥有这个Mutex。如果SomeClass使用一个AutoResetEvent而不是Mutex,线程在调用Method2的WaitOne方法时会阻塞。
如果需要递归锁,可以使用一个AutoResetEvent来简单地创建一个:
internal sealed class RecursiveAutoResetEvent : IDisposable { private AutoResetEvent m_lock = new AutoResetEvent(true); private Int32 m_owningThreadId = 0; private Int32 m_recursionCount = 0; public void Enter() { // Obtain the calling thread's unique Int32 ID Int32 currentThreadId = Thread.CurrentThread.ManagedThreadId; // If the calling thread owns the lock, increment the recursion count if (m_owningThreadId == currentThreadId) { m_recursionCount++; return; } // The calling thread doesn't own the lock, wait for it m_lock.WaitOne(); // The calling now owns the lock, initialize the owning thread ID & recursion count m_owningThreadId = currentThreadId; m_recursionCount = 1; } public void Leave() { // If the calling thread doesn't own the lock, we have an error if (m_owningThreadId != Thread.CurrentThread.ManagedThreadId) throw new InvalidOperationException(); // Subtract 1 from the recursion count if (m_recursionCount == 0) { // If the recursion count is 0, then no thread owns the lock m_owningThreadId = 0; m_lock.Set(); // Wake up 1 waiting thread (if any) } } public void Dispose() { m_lock.Dispose(); } }
虽然RecursiveAutoResetEvent类的行为和Mutex类完全一样,但在一个线程试图递归地获取锁时,它的性能会好得多,因为现在跟踪线程所有权和递归的都是托管代码。只有在第一次获取AutoResetEvent,或者最后把它放弃给其他线程时,线程才需要从托管代码转换为内核代码。
浙公网安备 33010602011771号