65.9K
CodeProject 正在变化。 阅读更多。
Home

C#/.NET 线程初学指南:第 n 部分之 3

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.89/5 (253投票s)

2008年6月30日

CPOL

19分钟阅读

viewsIcon

387476

downloadIcon

2641

本文主要介绍如何控制不同线程的同步。

引言

我恐怕得说,我就是那种如果没有事做就会感到无聊的人。所以现在我终于觉得我掌握了 WPF 的基础知识,是时候转向其他事情了。

我有一长串需要关注的事情(例如 WCF/WF/CLR Via C# 第 2 版),但最近我换了一份新工作(并且成功了,但最终还是拒绝了),这份工作要求我非常了解多线程。虽然我认为自己在多线程方面做得相当不错,但我还是觉得,嗯,我多线程还可以,但总能做得更好。因此,我决定撰写一系列关于 .NET 中多线程的文章。

这个系列无疑会很大程度上借鉴我购买的优秀的 Visual Basic .NET Threading Handbook,它很好地填补了我以及你们在 MSDN 中的空白。

我猜测这个主题将涵盖从简单到中等到高级的内容,并且会涉及很多 MSDN 上的内容,但我希望也能给出我自己的见解。所以,如果它看起来有点像 MSDN,请原谅我。

我不知道确切的时间表,但它最终可能会是这样的:

我想最好的方式就是直接开始。不过在开始之前有一点需要注意,我将使用 C# 和 Visual Studio 2008。

我将在本文中尝试涵盖的内容是:

本文将重点介绍如何控制不同线程的同步。

Wait Handles

要理解如何让多个线程协同工作,必须经历的第一个步骤是理解如何对操作进行排序。例如,假设我们有以下问题:

  1. 我们需要创建一个订单
  2. 我们需要保存订单,但这必须在获得订单号之后才能进行
  3. 我们需要打印订单,但这必须在订单保存到数据库之后才能进行

现在,这些看起来可能很简单,甚至不需要多线程,但为了演示的目的,让我们假设每个步骤都是一个耗时的操作,涉及对一个虚构数据库的多次调用。

从上面的简述可以看出,我们不能在步骤 1 完成之前执行步骤 2,也不能在步骤 2 完成之前执行步骤 3。这是一个难题。当然,我们可以将所有这些都放在一个庞大的代码块中,但这违背了我们试图通过并发来提高应用程序响应性的初衷(请记住,这些虚构的步骤都非常耗时)。

那么我们该怎么办呢?我们知道想要将这三个步骤多线程化,但肯定会遇到一些问题,因为我们**无法**保证哪个线程会先启动,正如我们在 第 2 部分 中所见。嗯,这不太好。幸运的是,有所帮助。

在 .NET 中有一个 `WaitHandle` 的概念,它允许线程等待一个特定的 `WaitHandle`,直到 `WaitHandle` 告诉等待的线程可以继续执行。这个机制被称为信号与等待。当一个线程等待一个 `WaitHandle` 时,它会被阻塞,直到 `WaitHandle` 被发出信号,这允许等待的线程被解除阻塞并继续工作。

我喜欢用火车站的交通灯来比喻 `WaitHandle` 的基本原理。当障碍物(`WaitHandle`)没有发出信号时,我们(等待的线程)必须等待障碍物被抬起(发出信号)。

至少我是这么想的。那么 .NET 在 `WaitHandle` 方面为我们提供了哪些选项呢?

我们提供了以下类:

  • System.Threading.WaitHandle
    • System.Threading.EventWaitHandle
    • System.Threading.Mutex
    • System.Threading.Semaphore

从这个层次结构可以看出,`System.Threading.WaitHandle` 是许多其他 `System.Threading.WaitHandle` 派生类的基类。在我们深入研究这些 `System.Threading.WaitHandle` 派生类的具体细节之前,有几件重要的事情需要先解释一下。

一些重要的通用(但并非全部)方法如下:

SignalAndWait

此方法有几种重载,但基本思想是,将一个 `System.Threading.WaitHandle` 发出信号,同时**等待另一个** `System.Threading.WaitHandle` 来接收信号。

WaitAll (WaitHandle 上的静态方法)

此方法有几种重载,但基本思想是将一个 `System.Threading.WaitHandle` 数组传递给 `WaitAll` 方法,然后**所有**这些 `System.Threading.WaitHandle` 都将被等待以接收信号。

WaitAny (WaitHandle 上的静态方法)

此方法有几种重载,但基本思想是将一个 `System.Threading.WaitHandle` 数组传递给 `WaitAny` 方法,然后**任何一个**这些 `System.Threading.WaitHandle` 都将被等待以接收信号。

WaitOne

此方法有几种重载,但基本思想是当前 `System.Threading.WaitHandle` 将被等待以接收信号。

现在让我们集中关注 `System.Threading.EventWaitHandle`。

EventWaitHandle

`EventWaitHandle` 是一个 `WaitHandle`,它有两个更具体的类:`ManualResetEvent` 和 `AutoResetEvent`,它们继承自它,并且更常用。因此,我将花时间讨论这两个子类。您需要注意的一点是,`EventWaitHandle` 对象可以通过使用 `EventResetMode` 枚举的两个值之一来充当其子类的作用,这两个值可以在构造新的 `EventWaitHandle` 对象时使用。

因此,让我们再深入研究一下 `ManualResetEvent` 和 `AutoResetEvent` 对象,因为它们更常用。

AutoResetEvent

来自 MSDN

“一个线程通过在 `AutoResetEvent` 上调用 `WaitOne` 来等待信号。如果 `AutoResetEvent` 处于非信号状态,线程将阻塞,等待当前控制资源的线程通过调用 `Set` 来发出资源可用的信号。”

调用 `Set` 会向 `AutoResetEvent` 发出信号,以释放一个等待的线程。`AutoResetEvent` 保持信号状态,直到一个等待的线程被释放,然后自动返回到非信号状态。如果没有线程在等待,状态将无限期地保持信号状态。”

用通俗的话来说,当使用 `AutoResetEvent` 时,当 `AutoResetEvent` 被设置为已发出信号时,第一个停止阻塞(停止等待)的线程将导致 `AutoResetEvent` 进入重置状态,这样任何其他正在等待 `AutoResetEvent` 的线程**必须**再次等待它发出信号。

让我们考虑一个简单的例子,其中启动了两个线程。第一个线程将运行一段时间,然后发出信号(调用 `Set`)一个最初处于非信号状态的 `AutoResetEvent`;然后第二个线程将等待 `AutoResetEvent` 发出信号。第二个线程还将等待第二个 `AutoResetEvent`;唯一的区别是第二个 `AutoResetEvent` 最初处于信号状态,因此不需要等待。

以下是一些说明此点的代码

using System;
using System.Threading;

namespace AutoResetEventTest
{
    class Program
    {
        public static Thread T1;
        public static Thread T2;
        //This AutoResetEvent starts out non-signalled
        public static AutoResetEvent ar1 = new AutoResetEvent(false);
        //This AutoResetEvent starts out signalled
        public static AutoResetEvent ar2 = new AutoResetEvent(true);

        static void Main(string[] args)
        {

            T1 = new Thread((ThreadStart)delegate
                {
                    Console.WriteLine(
                        "T1 is simulating some work by sleeping for 5 secs");
                    //calling sleep to simulate some work
                    Thread.Sleep(5000);
                    Console.WriteLine(
                        "T1 is just about to set AutoResetEvent ar1");
                    //alert waiting thread(s)
                    ar1.Set();
                });

            T2 = new Thread((ThreadStart)delegate
            {
                //wait for AutoResetEvent ar1, this will wait for ar1 to be signalled
                //from some other thread
                Console.WriteLine(
                    "T2 starting to wait for AutoResetEvent ar1, at time {0}", 
                    DateTime.Now.ToLongTimeString());
                ar1.WaitOne();
                Console.WriteLine(
                    "T2 finished waiting for AutoResetEvent ar1, at time {0}", 
                    DateTime.Now.ToLongTimeString());

                //wait for AutoResetEvent ar2, this will skip straight through
                //as AutoResetEvent ar2 started out in the signalled state
                Console.WriteLine(
                    "T2 starting to wait for AutoResetEvent ar2, at time {0}",
                    DateTime.Now.ToLongTimeString());
                ar2.WaitOne();
                Console.WriteLine(
                    "T2 finished waiting for AutoResetEvent ar2, at time {0}",
                    DateTime.Now.ToLongTimeString());
            });
            
            T1.Name = "T1";
            T2.Name = "T2";
            T1.Start();
            T2.Start();
            Console.ReadLine();
        }
    }
}

这将产生以下输出,其中可以看到 T1 等待 5 秒(模拟工作),T2 等待 `AutoResetEvent` “`ar1`”发出信号,但不必等待 `AutoResetEvent` “`ar2`”,因为它在构造时已经处于信号状态。

ManualResetEvent

来自 MSDN

“当一个线程开始一项必须在其他线程继续之前完成的活动时,它会调用 `Reset` 将 `ManualResetEvent` 置于非信号状态。可以将此线程视为控制 `ManualResetEvent`。调用 `WaitOne` 在 `ManualResetEvent` 上的线程将阻塞,等待信号。当控制线程完成活动时,它会调用 Set 以发出信号,允许等待的线程继续。所有等待的线程都将释放。”

一旦被发出信号,`ManualResetEvent` 就会保持信号状态,直到被手动重置。也就是说,调用 `WaitOne` 会立即返回。”

用通俗的话来说,当使用 `ManualResetEvent` 时,当 `ManualResetEvent` 被设置为已发出信号时,所有阻塞(等待)它的线程都将被允许继续,直到* `ManualResetEvent` *被置于重置状态。

考虑以下代码片段

using System;
using System.Threading;

namespace ManualResetEventTest
{
    /// <summary>
    /// This simple class demonstrates the usage of an ManualResetEvent
    /// in 2 different scenarios, bith in the non-signalled state and the 
    /// signalled state
    /// </summary>
    class Program
    {
        public static Thread T1;
        public static Thread T2;
        public static Thread T3;
        //This ManualResetEvent starts out non-signalled
        public static ManualResetEvent mr1 = new ManualResetEvent(false);

        static void Main(string[] args)
        {
            T1 = new Thread((ThreadStart)delegate
            {
                Console.WriteLine(
                    "T1 is simulating some work by sleeping for 5 secs");
                //calling sleep to simulate some work
                Thread.Sleep(5000);
                Console.WriteLine(
                    "T1 is just about to set ManualResetEvent ar1");
                //alert waiting thread(s)
                mr1.Set();
            });

            T2 = new Thread((ThreadStart)delegate
            {
                //wait for ManualResetEvent mr1, this will wait for ar1
                //to be signalled from some other thread
                Console.WriteLine(
                    "T2 starting to wait for ManualResetEvent mr1, at time {0}",
                    DateTime.Now.ToLongTimeString());
                mr1.WaitOne();
                Console.WriteLine(
                    "T2 finished waiting for ManualResetEvent mr1, at time {0}",
                    DateTime.Now.ToLongTimeString());
            });

            T3 = new Thread((ThreadStart)delegate
            {
                //wait for ManualResetEvent mr1, this will wait for ar1
                //to be signalled from some other thread
                Console.WriteLine(
                    "T3 starting to wait for ManualResetEvent mr1, at time {0}",
                    DateTime.Now.ToLongTimeString());
                mr1.WaitOne();
                Console.WriteLine(
                    "T3 finished waiting for ManualResetEvent mr1, at time {0}",
                    DateTime.Now.ToLongTimeString());
            });

            T1.Name = "T1";
            T2.Name = "T2";
            T3.Name = "T3";
            T1.Start();
            T2.Start();
            T3.Start();
            Console.ReadLine();

        }
    }
}

这将产生以下屏幕截图

可以看出,启动了三个线程(T1-T3),T2 和 T3 都等待 `ManualResetEvent` “`mr1`”,该信号量仅在 T1 的代码块内被置于信号状态。当 T1 将 `ManualResetEvent` “`mr1`”置于信号状态(通过调用 `Set()` 方法)时,这允许等待的线程继续。由于 T2 和 T3 都等待 `ManualResetEvent` “`mr1`”,并且它处于信号状态,因此 T2 和 T3 都继续执行。`ManualResetEvent` “`mr1`”从未被重置,因此 T2 和 T3 都可以自由地继续执行它们各自的代码块。

回到最初的问题

回想一下最初的问题

  1. 我们需要创建一个订单
  2. 我们需要保存订单,但这必须在获得订单号之后才能进行
  3. 我们需要打印订单,但这必须在订单保存到数据库之后才能进行

现在解决这个问题应该很简单了。我们只需要一些 `WaitHandle` 来控制执行顺序,其中步骤 2 等待步骤 1 发出的 `WaitHandle` 信号,步骤 3 等待步骤 2 发出的 `WaitHandle` 信号。很简单,不是吗?我们来看看示例代码?这是其中的一部分,我只是选择了使用 `AutoResetEvent`。

using System;
using System;
using System.Threading;

namespace OrderSystem
{
    /// <summary>
    /// This simple class demonstrates the usage of an AutoResetEvent
    /// to create some synchronized threads that will carry out the 
    /// following
    /// -CreateOrder
    /// -SaveOrder
    /// -PrintOrder
    /// 
    /// Where it is assumed that these 3 task MUST be executed in this
    /// order, and are interdependant
    /// </summary>
    public class Program
    {
        public static Thread CreateOrderThread;
        public static Thread SaveOrderThread;
        public static Thread PrintOrderThread;
        //This AutoResetEvent starts out non-signalled
        public static AutoResetEvent ar1 = new AutoResetEvent(false);
        public static AutoResetEvent ar2 = new AutoResetEvent(false);

        static void Main(string[] args)
        {
            CreateOrderThread = new Thread((ThreadStart)delegate
            {
                Console.WriteLine(
                    "CreateOrderThread is creating the Order");
                //calling sleep to simulate some work
                Thread.Sleep(5000);
                //alert waiting thread(s)
                ar1.Set();
            });

            SaveOrderThread = new Thread((ThreadStart)delegate
            {
                //wait for AutoResetEvent ar1, this will wait for ar1
                //to be signalled from some other thread
                ar1.WaitOne();
                Console.WriteLine(
                    "SaveOrderThread is saving the Order");
                //calling sleep to simulate some work
                Thread.Sleep(5000);
                //alert waiting thread(s)
                ar2.Set();
            });

            PrintOrderThread = new Thread((ThreadStart)delegate
            {
                //wait for AutoResetEvent ar1, this will wait for ar1
                //to be signalled from some other thread
                ar2.WaitOne();
                Console.WriteLine(
                    "PrintOrderThread is printing the Order");
                //calling sleep to simulate some work
                Thread.Sleep(5000);
            });

            CreateOrderThread.Name = "CreateOrderThread";
            SaveOrderThread.Name = "SaveOrderThread";
            PrintOrderThread.Name = "PrintOrderThread";
            CreateOrderThread.Start();
            SaveOrderThread.Start();
            PrintOrderThread.Start();
            Console.ReadLine();
        }
    }
}

这将产生以下屏幕截图

信号量

Semaphore 继承自 `System.Threading.WaitHandle`;因此,它具有 `WaitOne()` 方法。您还可以使用静态的 `System.Threading.WaitHandle`、`WaitAny()`、`WaitAll()`、`SignalAndWait()` 方法来处理更复杂的任务。

我读到了一些关于信号量像夜总会的东西。它有一个固定的容量,由保镖强制执行。当满员时,没有人可以进入俱乐部,直到有人离开俱乐部,届时才能进入一个人。

让我们看一个简单的例子,其中信号量被设置为能够处理两个并发请求,并且总容量为 5。

using System;
using System;
using System.Threading;

namespace SemaphoreTest
{
    class Program
    {
        //initial count to be satified concurrently = 2
        //maximum capacity = 5
        static Semaphore sem = new Semaphore(2, 5);

        static void Main(string[] args)
        {
            for (int i = 0; i < 10; i++)
            {
                new Thread(RunThread).Start("T" + i);
            }

            Console.ReadLine();
        }

        static void RunThread(object threadID)
        {
            while (true)
            {
                Console.WriteLine(string.Format(
                    "thread {0} is waiting on Semaphore", 
                    threadID));
                sem.WaitOne();

                try
                {
                    Console.WriteLine(string.Format(
                        "thread {0} is in the Semaphore, and is now Sleeping", 
                        threadID));
                    Thread.Sleep(100);
                    Console.WriteLine(string.Format(
                        "thread {0} is releasing Semaphore", 
                        threadID));
                }
                finally
                {
                    //Allow another into the Semaphore
                    sem.Release();
                }
            }
        }
    }
}

这将产生类似这样的结果

这个例子确实展示了一个限制并发线程数量的 `Semaphore` 的工作示例,但它不是一个非常有用的例子。我现在将概述一些**部分**完成的代码,我们可以使用 `Semaphore` 来限制试图访问具有有限连接数的数据库的线程数量。数据库最多只能接受三个并发连接。正如我所说,这段代码是不完整的,目前无法正常工作;它仅用于演示目的,并且不属于附带的演示应用程序。

using System;
using System.Threading;
using System.Data;
using System.Data.SqlClient;

namespace SemaphoreTest
{
    /// <summary>
    /// This example shows partially completed skeleton
    /// code for consuming a limited resource, such as a
    /// DB connection using a Semaphore
    /// 
    /// NOTE : THIS CODE WILL NOT RUN, ITS INCOMPLETE
    ///        DEMO ONLY CODE
    /// </summary>
    class RestrictedDBConnectionStringAccessUsingSemaphores
    {

        //initial count to be satified concurrently = 1
        //maximum capacity = 3
        static Semaphore sem = new Semaphore(1, 3);

        static void Main(string[] args)
        {
            //start 5 new threads that all require a Database connection
            //but as a DB connection is limited to 3, we use a Semaphore
            //to ensure that the number of active connections will never
            //exceed the total allowable DB connections
            new Thread(RunCustomersThread).Start("ReadCustomersFromDB");
            new Thread(RunOrdersThread).Start("ReadOrdersFromDB");
            new Thread(RunProductsThread).Start("ReadProductsFromDB");
            new Thread(RunSuppliersThread).Start("ReadSuppliersFromDB");
            Console.ReadLine();
        }

        static void RunCustomersThread(object threadID)
        {
            //wait for the Semaphore
            sem.WaitOne();
            //the MAX DB connections must be within its limited
            //so proceed to use the DB
            using (new SqlConnection("<SOME_DB_CONNECT_STRING>"))
            {
                //do our business with the database
            }
            //Done with DB, so release Semaphore which will
            //allow another into the Semaphore
            sem.Release();
        }

        static void RunOrdersThread(object threadID)
        {
            //wait for the Semaphore
            sem.WaitOne();
            //the MAX DB connections must be within its limited
            //so proceed to use the DB
            using (new SqlConnection("<SOME_DB_CONNECT_STRING>"))
            {
                //do our business with the database
            }
            //Done with DB, so release Semaphore which will
            //allow another into the Semaphore
            sem.Release();
        }

        static void RunProductsThread(object threadID)
        {
            //wait for the Semaphore
            sem.WaitOne();
            //the MAX DB connections must be within its limited
            //so proceed to use the DB
            using (new SqlConnection("<SOME_DB_CONNECT_STRING>"))
            {
                //do our business with the database
            }
            //Done with DB, so release Semaphore which will
            //allow another into the Semaphore
            sem.Release();
        }

        static void RunSuppliersThread(object threadID)
        {
            //wait for the Semaphore
            sem.WaitOne();
            //the MAX DB connections must be within its limited
            //so proceed to use the DB
            using (new SqlConnection("<SOME_DB_CONNECT_STRING>"))
            {
                //do our business with the database
            }
            //Done with DB, so release Semaphore which will
            //allow another into the Semaphore
            sem.Release();
        }
    }
}

从这个小程序可以看出,`Semaphore` 只允许最多三个线程(在 `Semaphore` 构造函数中设置),因此我们可以确信数据库连接也将保持在限制范围内。

互斥体

Mutex 的工作方式与 `lock` 语句(我们将在下面的“临界区”部分讨论)非常相似,所以我不会过多强调它。但 Mutex 相对于 `lock` 语句和 `Monitor` 对象的主要优势在于,它可以跨多个进程工作,提供计算机范围的锁,而不是应用程序范围的锁。

本文的技术审稿人 Sasha Goldshtein 还提到,使用 Mutex 时,它在终端服务上不起作用。

应用程序单例

Mutex 最常见的用途之一是确保**只有一个**应用程序实例正在运行。

让我们看一些代码。这段代码确保应用程序的单例。任何新实例都会等待 5 秒(以防当前运行的实例正在关闭),然后假定应用程序的先前实例已在运行并退出。

using System;
using System.Threading;

namespace MutexTest
{
    class Program
    {
        //start out with un-owned mutex
        static Mutex mutex = new Mutex(false,"MutexTest");

        static void Main(string[] args)
        {
            //check to see if there is another instance, allow 5 secs
            //another instance may be in process of closing right now
            if(!mutex.WaitOne(TimeSpan.FromSeconds(5)))
            {
                Console.WriteLine("MutexTest already running! Exiting");
                return;
            }
            try
            {
                Console.WriteLine("MutexTest Started");
                Console.ReadLine();
            }
            finally
            {
                //release the mutx to allow, possible future instance to run
                mutex.ReleaseMutex();
            }
        }
    }
}

所以如果我们启动一个该应用程序的实例,我们会得到

当我们尝试运行另一个副本时,我们会得到以下结果(在延迟 5 秒后)

临界区(又名锁)

锁定是一种机制,用于确保一次只有一个线程可以进入代码的特定部分。这是通过使用所谓的锁来实现的。实际被锁定的代码部分称为临界区。有多种不同的锁定代码部分的方法,将在下面介绍。

但在我们继续之前,让我们先看看为什么我们可能需要这些“临界区”代码。考虑下面的代码片段

using System;
using System.Threading;

namespace NoLockTest
{
    /// <summary>
    /// This program is not thread safe
    /// as it could be accessed by 2 different 
    /// simultaneous threads. As one thread could 
    /// be set item2 to 0, just at the point that 
    /// another thread was doing the division, 
    /// leading to a DivideByZeroException
    /// </summary>
    class Program
    {
        static int item1=54, item2=21;
        public static Thread T1;
        public static Thread T2;

        static void Main(string[] args)
        {
            T1 = new Thread((ThreadStart)delegate
                {
                    DoCalc();
                });
            T2 = new Thread((ThreadStart)delegate
            {
                DoCalc();
            });
            T1.Name = "T1";
            T2.Name = "T2";
            T1.Start();
            T2.Start();
            Console.ReadLine();
        }

        private static void DoCalc()
        {
              item2 = 10;
            if (item1 != 0)
                Console.WriteLine(item1 / item2);
            item2 = 0;
        }
    }
}

这段代码不是线程安全的,因为它可以被两个不同的同时线程访问。一个线程可能在另一个线程进行除法时将 `item2` 设置为 0,这会导致 `DivideByZeroException`。

现在,这个问题可能每运行此代码 500 次才出现一次,但这就是多线程问题的本质;它们只偶尔出现一次,因此非常难以找到。在编写代码之前,您确实需要考虑所有可能性,并确保已采取正确的安全措施。

幸运的是,我们可以使用锁(又名临界区)来解决这个问题。下面是上面代码的修订版本

using System;
using System.Threading;

namespace LockTest
{
    /// <summary>
    /// This shows how to create a critical section
    /// using the lock keyword
    /// </summary>
    class Program
    {
        static object syncLock = new object();
        static int item1 = 54, item2 = 21;
        public static Thread T1;
        public static Thread T2;

        static void Main(string[] args)
        {
            T1 = new Thread((ThreadStart)delegate
            {
                DoCalc();
            });
            T2 = new Thread((ThreadStart)delegate
            {
                DoCalc();
            });
            T1.Name = "T1";
            T2.Name = "T2";
            T1.Start();
            T2.Start();
            Console.ReadLine();
        }

        private static void DoCalc()
        {
            lock (syncLock)
            {
                item2 = 10;
                if (item1 != 0)
                    Console.WriteLine(item1 / item2);
                item2 = 0;
            }
        }
    }
}

在此示例中,我们通过使用 `lock` 关键字引入了创建临界区的第一个可能技术。一次只有一个线程可以锁定同步对象(在本例中为 `syncLock`)。任何争用锁的线程都会被阻塞,直到锁被释放。争用锁的线程会进入“就绪队列”,并按先来先服务的顺序获得访问权。

有些人使用 `lock(this)` 或 `lock(typeof(MyClass))` 作为同步对象。这是一个坏主意,因为这两个都是公开可见的对象,所以理论上,外部实体可以使用它们进行同步并干扰您的线程,从而产生许多有趣的问题。因此,最好始终使用私有同步对象。

现在我想简要谈谈锁定(创建临界区)的不同方法。

Lock 关键字

我们已经看到了第一个示例,其中我们使用了 `lock` 关键字,我们可以用它来锁定特定的对象。这是一种相当常见的方法。

值得指出的是,`lock` 关键字实际上只是处理 `Monitor` 类的一个快捷方式,如下所示。

Monitor 类

`System.Threading` 命名空间包含一个名为 `Monitor` 的类,它可以做到与 `lock` 关键字完全相同的事情。如果我们考虑使用 `lock` 关键字的同一个线程安全代码片段,您应该会看到 `Monitor` 做了同样的工作。

using System;
using System.Threading;

namespace LockTest
{
    /// <summary>
    /// This shows how to create a critical section
    /// using the Monitor class
    /// </summary>
    class Program
    {
        static object syncLock = new object();
        static int item1 = 54, item2 = 21;


        static void Main(string[] args)
        {
            Monitor.Enter(syncLock);
            try
            {
                if (item1 != 0)
                    Console.WriteLine(item1 / item2);
                item2 = 0;
            }
            finally
            {
                Monitor.Exit(syncLock);
            }
            Console.ReadLine();
        }
    }
}

而 `lock` 关键字只是以下 `Monitor` 代码的语法糖

Monitor.Enter(syncLock);
try
{

}
finally
{
    Monitor.Exit(syncLock);
}

`lock` 关键字实际上与此代码相同。

MethodImpl.Synchronized 属性

最后一种方法依赖于可以使用一个属性来修饰一个方法,表明该方法应被视为已同步。让我们看看这个

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;

namespace MethodImplSynchronizedTest
{
    /// <summary>
    /// This shows how to create a critical section
    /// using the System.Runtime.CompilerServices.MethodImplAttribute
    /// </summary>
    class Program
    {
        static int item1=54, item2=21;

        static void Main(string[] args)
        {
            //make a call to different method
            //as cant Synchronize Main method
            DoWork();
        }

        [System.Runtime.CompilerServices.MethodImpl
        (System.Runtime.CompilerServices.MethodImplOptions.Synchronized)]
        private static void DoWork()
        {
            if (item1 != 0)
                Console.WriteLine(item1 / item2);
            item2 = 0;
            Console.ReadLine();
        }
    }
}

这个简单的例子表明,您可以使用 `System.Runtime.CompilerServices.MethodImplAttribute` 将一个方法标记为已同步(临界区)。

需要注意的一点是,如果您锁定整个方法,那么您就可能错失了更好的并发编程机会,因为性能提升可能不如单线程模型运行该方法。因此,您应该尽量将临界区限制在只需要在多个线程之间安全访问的字段周围。所以尝试在读取/写入公共字段时锁定。

当然,有些时候您可能需要将整个方法标记为临界区,但这取决于您。只需注意,锁的粒度(范围)通常应尽可能小。

其他对象

有几个额外的类我认为值得一提,它们将在下面进一步讨论

Interlocked

“一个语句是原子的,如果它作为单个不可分割的指令执行。严格的原子性排除了任何可能的抢占。在 C# 中,对 32 位或更少位字段的简单读取或赋值是原子的(假设是 32 位 CPU)。对较大字段的操作是非原子的,将多个读/写操作组合在一起的语句也是非原子的。”

--Threading in C#, Joseph Albahari

考虑以下代码:

using System;
using System.Threading;

namespace AtomicTest
{
    class AtomicTest
    {
        static int x, y;
        static long z;

        static coid Test()
        {
            long myVar;

            x = 3;          //Atomic
            z = 3;          //Non atomic as Z is 64 bits
            myVar = z;      //Non atomic as Z is 64 bits
            y += x;         //Non atomic read and write
            x++;            //Non atomic read and write
        }
    }
}

解决这个问题的一种方法可能是将非原子操作包装在 `lock` 语句中。然而,.NET 有一个类提供了更简单/更快的方法。这就是 `Interlocked` 类。使用 `Interlocked` 比使用 `lock` 语句更安全,因为 `Interlocked` 永远不会阻塞。

MSDN stated

“此类的方法有助于防止在调度程序切换上下文时发生的错误,而线程正在更新一个可以被其他线程访问的变量,或者当两个线程在单独的处理器上并发执行时。此类成员不会抛出异常。”

这是一个使用 `Interlocked` 类的小例子

using System;
using System.Threading;

namespace InterlockedTest
{
    class Program
    {
        static long currentValue;

        static void Main(string[] args)
        {
            //simple increment/decrement operations
            Interlocked.Increment(ref currentValue);
            Console.WriteLine(String.Format(
                "The value of currentValue is {0}", 
                Interlocked.Read(ref currentValue)));

            Interlocked.Decrement(ref currentValue);
            Console.WriteLine(String.Format(
                "The value of currentValue is {0}", 
                Interlocked.Read(ref currentValue)));

            Interlocked.Add(ref currentValue, 5);
            Console.WriteLine(String.Format(
                "The value of currentValue is {0}", 
                Interlocked.Read(ref currentValue)));



            //read a 64 bit value
            Console.WriteLine(String.Format(
                "The value of currentValue is {0}", 
                Interlocked.Read(ref currentValue)));


            Console.ReadLine();
        }
    }
}

这将产生以下结果

`Interlocked` 类还提供各种其他方法,例如

  • `CompareExchange(location1,value,comparand)`:如果 `comparand` 与 `location1` 中的值相等,则将 `value` 存储在 `location1` 中。否则,不执行任何操作。比较和交换操作作为原子操作执行。`CompareExchange` 的返回值是 `location1` 中的原始值,无论交换是否发生。
  • `Exchange(location1,value)`:将一个位置设置为指定值,并以原子操作返回原始值。

这两个 `Interlocked` 方法可用于实现无锁(无等待)算法和数据结构,而这些算法和数据结构否则必须使用完整的内核锁(如 `Monitor`、`Mutex` 等)来实现。

Volatile

`volatile` 关键字可以用于共享字段。 `volatile` 关键字指示一个字段可以被程序中的某些内容修改,例如操作系统、硬件或并发执行的线程。

MSDN stated

“系统总是会在请求时读取易失性对象的当前值,即使前一条指令要求的是同一个对象的值。此外,对象的值在赋值时立即写入。

`volatile` 修饰符通常用于一个字段,该字段由多个线程访问,而不使用 `lock` 语句来序列化访问。使用 `volatile` 修饰符可确保一个线程检索到由另一个线程写入的最新值。”

ReaderWriterLockSlim

很多时候,类型的实例在读取操作方面是线程安全的,但在更新方面不是。虽然这个问题可以通过使用 `lock` 语句来解决,但如果有很多读取但很少写入,这可能会变得相当受限制。`ReaderWriterLockSlim` 类旨在帮助解决这个问题。

`ReaderWriterLockSlim` 类(.NET 3.5 新增)提供两种锁:读锁和写锁。写锁是排他的,而读锁与其他读锁兼容。

简而言之,持有写锁的线程会阻塞所有试图获取读锁**或**写锁的其他线程。如果没有线程当前持有写锁,任何数量的线程都可以获取读锁。

`ReaderWriterLockSlim` 类提供以促进这些锁的主要方法如下:

  • EnterReadLock (*)
  • ExitReadLock
  • EnterWriteLock (*)
  • ExitWriteLock (*)

其中 * 表示也有更安全的“try”版本的该方法可用,该方法支持超时。

让我们看一个稍微修改过的该示例(原始示例由 Threading in C#, Joseph Albahari 提供)

using System;
using System;
using System.Threading;
using System.Collections.Generic;

namespace ReaderWriterLockSlimTest
{
    /// <summary>
    /// This simple class demonstrates the usage a Reader/Writer 
    /// situation, using the ReaderWriterLockSlim class
    /// </summary>
    class Program
    {
        static ReaderWriterLockSlim rw = new ReaderWriterLockSlim();
        static List<int> items = new List<int>();
        static Random rand = new Random();

        static void Main(string[] args)
        {
            //start some readers
            new Thread(Read).Start("R1");
            new Thread(Read).Start("R2");
            new Thread(Read).Start("R3");

            //start some writers
            new Thread(Write).Start("W1");
            new Thread(Write).Start("W2");
        }

        static void Read(object threadID)
        {
            //do read
            while (true)
            {
                try
                {
                    rw.EnterReadLock();
                    Console.WriteLine("Thread " + threadID +
                        " reading common source");
                    foreach (int i in items)
                        Thread.Sleep(10);
                }
                finally
                {
                    rw.ExitReadLock();
                }
            }
        }

        static void Write(object threadID)
        {
            //do write
            while (true)
            {
                int newNumber = GetRandom(100);
                try
                {
                    rw.EnterWriteLock();
                    items.Add(newNumber);
                }
                finally
                {
                    rw.ExitWriteLock();
                    Console.WriteLine("Thread " + threadID +
                        " added " + newNumber);
                    Thread.Sleep(100);
                }
            }
        }

        static int GetRandom(int max)
        {
            //lock on the Random object
            lock (rand)
                return rand.Next(max);
        }
    }
}

这将显示类似以下内容

应该注意的是,`System.Threading` 命名空间中还有一个 `ReaderWriterLock` 类。MSDN 关于 `ReaderWriterLockSlim`(.NET 3.5)和 `ReaderWriterLock`(.NET 2.0)之间区别的说法如下:

`ReaderWriterLockSlim` 与 `ReaderWriterLock` 类似,但它简化了递归以及升级和降级锁状态的规则。`ReaderWriterLockSlim` 避免了许多潜在的死锁情况。此外,`ReaderWriterLockSlim` 的性能明显优于 `ReaderWriterLock`。 `ReaderWriterLockSlim` 推荐用于所有新开发。

根据 Sasha Goldshtein(Sela Group 高级顾问兼讲师)的说法,Jeffrey Richter(著名作者,《CLR Via C#》一书)修订了 `ReaderWriterLock` 版本,据称可以提供更好的性能。Sasha 还告诉我,Vista 在操作系统中添加了另一个 RWL。

我确实选对了技术审稿人,感谢 Sasha…… 这也让我引出了下面的真正感谢。

特别感谢

我个人要感谢 Sasha Goldshtein(Sela Group 高级顾问兼讲师)在技术审阅本文方面提供的帮助。

Sasha Goldshtein 是我在本系列前两篇文章之后与他聊天认识的人。他似乎总是在评论我出错的地方,或者提出我不知道的问题。所以我联系了 Sasha,问他是否愿意担任本系列其余文章的技术审稿人,他欣然同意了。所以,谢谢 Sasha,来自 Sacha(我)。

我们完成了

好了,这次我想说的就这些了。我只是想说,我**完全**意识到本文借鉴了许多来源的材料;但是,我认为它可以提醒潜在的多线程新手注意他们不知道需要查找的类/对象。因此,我仍然认为本文中有一些有用的东西。嗯,这本来就是我的想法。我只希望您同意;如果同意,请告诉我,并投一票。

下次

下次,我们将讨论线程池。

如果您喜欢这篇文章,能否请您为它投票?非常感谢。

参考文献

  1. C# 中的多线程,Joseph Albahari
  2. System.Threading MSDN 页面
  3. 线程对象和功能 MSDN 页面
  4. Visual Basic .NET 多线程,Wrox
© . All rights reserved.