第十一章 线程同步与并发访问共享资源

目录

1 死锁与数据存取错误

1.1 多线程程序中的 “死锁” 现象

1.2 多线程引发的数据存取错误

2 锁

2.1 锁定共享资源 —— Monitor

3 线程同步

3.1 等待句柄

3.2 使用互斥同步对象 Mutex

3.3 管理多个共享资源 —— Semaphore

3.4 线程同步事件类 —— EventWaitHandle

4 线程池

4.1 线程池简介

4.2 使用线程池

5 线程局部存储区

5.1 深入了解线程局部存储区

5.2 线程相关的静态字段


线程同步技术主要包括两个方面的内容:

一、多个线程推进顺序的控制问题

二、访问共享资源的问题


1 死锁与数据存取错误

1.1 多线程程序中的 “死锁” 现象

Join 方法如果使用不当,就会在线程之间形成死锁状态。以下代码为典型的死锁实例:

static Thread mainThread;
static void Main(string[] args)
{
   mainThread = Thread.CurrentThread;
   Thread m_objThread = new Thread(__PlayThread);
   m_objThread.Start();
   m_objThread.Join();
   Console.WriteLine("mainThread");
}

static void __PlayThread()
{
   mainThread.Join();
   Console.WriteLine("__PlayThread");
}

注意突出显示的两句代码:主线程在等待线程 A 运行结束,而线程 A 在运行过程中又等待主线程的运行结束。当死锁发生时,两个或多个线程相互等待,所有线程都无法再前进一步,程序会失去响应。

1. 线程推进循序不当造成线程间的循环等待。

2. 多个线程访问共享的资源。

1.2 多线程引发的数据存取错误

单 CPU 时,多线程访问 Info 类对象,一个线程在访问 Info.Result 属性时,在判断 if (num2 != 0)为true 后,可能在 NO.1 之后和 NO.2 之前处出现中断(线程挂起),此时另一个线程通过 DoSomething 方法修改 num2 的值为 0,中断恢复后,程序报错。双 CPU中,多线程访问 Info 类对象,情况更糟,一个线程访问 Info.Result 属性时,不管在 NO.1 之后和 NO.2 之前会不会中断,另一个线程都有可能通过 DoSomething 方法修改 num2 的值为0,程序报错。

class Info
{
   public int num1;
   public int num2;
   public int Result
   {
       get
       {
           if (num2 != 0)   // NO.1
           {
               return num1 / num2;     // NO.2
           }
           else
           {
               return 0;
           }
       }
   }

   public void DoSomething(int a1,int a2)
   {
       num1 = a1;
       num2 = a2;
   }
}

2 锁

访问共享资源,常用的方法是给资源加 “锁”,加上锁的共享资源一次只允许一个线程访问。从而避免多线程同时存取共享资源所带来的数据存取错误问题,付出的代价是程序性能的下降。

2.1 锁定共享资源 —— Monitor

由于多个线程同时访问共享数据时会造成数据存取错误,解决这一问题的方法是给共享的资源加上一把 “锁”,一次只允许一个线程访问共享资源。

Monitor 类可用于给共享资源加 “锁”。

如下代码所示,定义一个共享资源类:

class SharedResource
{
   public int InstanceCount = 0;
   public static int StaticCount = 0;
}

访问多线程共享的实例字段

private static void __PlayThread(object obj)
{
   // 访问实例字段
   Monitor.Enter(obj);      // 加锁
   int beginNumber = (obj as SharedResource).InstanceCount;
   Monitor.Exit(obj);     // 解锁
}

线程函数可能会被多个线程同时执行,在代码开头,使用 Monitor 类的 Enter 方法申请对共享对象 obj 的 “独家使用权” (即 “对象锁” ),之后就可以放心地访问它,访问完后,再调用 Monitor 类的 Exit 方法放弃对共享对象 obj 的 “独家使用权”。

在调用 Monitor 类的 Enter 方法申请对共享对象 obj 的 “独家使用权” 时,如果此对象已被其它线程所使用,即 “对象锁” 为另一线程所拥有,则申请锁的线程必须等待对方放弃 “对象锁” 之后(通过调用 Monitor 类的 Exit 方法实现),才可以继续运行。

由于共享对象加锁的功能非常常用,所以 C# 甚至在语言级别就提供了于 Monitor 类的 Enter 和 Eixt 方法等价的关键字 —— lock。
其格式为:

lock (obj)
{
   // 访问共享资源的代码
}

它完全等同于以下代码段:

try
{
   Monitor.Enter(obj);
   // 访问共享资源的代码......
}
finally
{
   Monitor.Exit(obj);
}

因此,在开发中推荐使用 lock 关键字而不是直接使用 Monitor 类。
访问多线程共享的静态字段

private static void __PlayThread(object obj)
{
   // 访问静态字段
   Monitor.Enter(typeof(SharedResource));
   int beginNumber = SharedResource.StaticCount;
   Monitor.Exit(typeof(SharedResource));
}

使用 Monitor 控制线程的推进顺序
如果访问共享资源的多个线程间有着顺序关系,比如要求 A 线程先访问,之后 B 线程才可以访问,则可以使用 Monitor 类的 Wait 和 Pulse 方法。其代码框架如下:

// A线程执行的代码
lock(obj)
{
   // 访问共享资源
   Monitor.Pulse(obj);       // 通知 B 线程可以访问共享资源 obj 了
}
--------------------------------------------------------------------------------------------
// B线程执行的代码
lock (obj)
{
    Monitor.Wait(obj);       // 等待 A 线程完成
    // 访问共享资源 obj ......
}

在程序运行时,A、B 线程同时申请共享资源 obj 的锁,若 B 先获得锁,它会调用 Monitor.Wait(obj) 方法先阻塞等待,同时放弃已获得的锁。这样,A 线程就可以获取锁并访问共享资源 obj,访问完毕,它调用 Monitor.Pulse(obj) 方法通知 B 线程它的工作已完成,之后放弃锁。现在 B 线程就可以在获取锁之后继续运行了。

这里要注意,一定要保证 B 线程先于 A 线程运行,这样才不会造成死锁。如果线程 A 先运行,则 B 线程不能再调用 Monitor.Wait(obj) 方法,否则将会造成死锁。

注:当 A 线程先运行时,有可能它在 B 线程开始运行之前就结束了,结果导致 B 线程的 Wait 方法就在 “等待一封永远也不会到的信”,从而陷入死锁状态。

3 线程同步

实现线程同步的关键在于提供这样一个种机制:某个线程在必要时可以暂停,等待其它线程的执行结束。

3.1 等待句柄

WaitHandle 被译为 “等待句柄”,它拥有两个互斥的状态:signaled 和 non-signaled。

WaitHandle 对象通常是多线程共享的,将其设置为 signaled 状态,等待此状态的线程即可投入运行,将其设置为 non-signaled 状态,则线程会阻塞,直到 WaitHandle 对象变为 signaled 状态为止。

WaitHandle 类定义了几个重要的线程等待方法:WaitOne、WaitAll 和 WitAny。

public virtual bool WaitOne();

当线程调用某个 WaitHandle 对象的 WaitOne 方法时,如果此对象居于 signaled 状态,线程可以继续执行,否则线程被阻塞。直到其它某个线程将 WaitHandle 对象置为 signaled 状态为止。

WaitAll 是一个静态方法,其定义如下:

public static bool WaitAll(WaitHandle[] waitHandles);

与 WaitOne 不同之处在于,线程这次是等待多个 WaitHandle 对象都变为 signaled 状态。

WaitAny 也是一个静态方法,它的方法参数与 WaitAll 一样,也可以让线程等待多个 WaitHandle 对象,但只要至少有一个 WaitHandle 对象变为 signaled 状态,线程即可继续进行。

public static int WaitAny(WaitHandle[] waitHandles);

注:WaitHandle 是一个抽象类,由子类负责实现具体的功能。

3.2 使用互斥同步对象 Mutex

Mutex 类派生自 WaitHandle 类,它主要用于提供对共享资源的独占访问,一次只有一个线程可以拥有 Mutex 对象。

Mutex 对象拥有两个状态:owned(拥有)和 unowned(不拥有)。

当线程需要独占访问某个资源时,它会申请一个 Mutex 对象,如果得到满足,其它线程必须等到这一线程完成访问并释放 Mutex 对象的所属权之后才有可能得到使用资源的权限。

一个线程要想拥有一个 Mutex 对象,它必须调用 Mutex 类的 Wait 系列方法之一提出申请。当拥有 Mutex 对象的线程完成了对于共享资源的访问后,必须及时调用 ReleaseMutex 方法释放 Mutex 对象。

如下示例:

static int value;
static Mutex m = new Mutex();
static void Main(string[] args)
{
   Thread m_Thread = new Thread(__PlayThread);
   m_Thread.Start();
   m_Thread.IsBackground = false;
   Console.WriteLine("正在计算,请耐心等待");
   m_Thread = new Thread(__PlayThread);
   m_Thread.Start();
   m_Thread.IsBackground = false;
}

static void __PlayThread()
{
   Console.WriteLine(Thread.CurrentThread.ManagedThreadId.ToString());
   m.WaitOne();
   value = Thread.CurrentThread.ManagedThreadId * 2;
   Thread.Sleep(1000);
   Console.WriteLine(value.ToString());
   m.ReleaseMutex();
}

第十一章 线程同步与并发访问共享资源_第1张图片

3.3 管理多个共享资源 —— Semaphore

针对多线程访问多个同类型资源的场景,.NET Framework 提供了 Semaphore 对象(称为 “信号量”)实现对多个资源的访问同步。

与 Mutex 一样,Semaphore 也派生自 WaitHandle 类,它在内部维护一个计数器,当一个线程调用 Semaphore.Wait 系列方法时,此计数器减 1,只要计数器还是一个正数,线程就不会阻塞。当计数器减到 0 时,再调用 Semaphore.Wait 系列方法的线程将被阻塞,直到有线程调用 Semaphore.Release 方法增加计数器值时,才有可能接触阻塞状态。

简单地说,Semaphore 对象一次可以使数个线程投入运行。

public Semaphore(int initialCount, int maximumCount);

第一个参数指明计数器的初始值,代表空闲的共享资源数目,第二个参数表明计数器的最大值,代表共享资源的总量。

当第一个参数为 0 时,说明所有的资源都被占用了,因而所有线程都会阻塞,这时,必须在代码中调用 Semaphore 对象的 Release 方法增加计数器值,阻塞的线程才有机会投入运行。

如果初始值等于最大值,说明所有共享资源都是空闲的。

private const int ComputerNum = 3;
public static Semaphore sp = new Semaphore(ComputerNum, ComputerNum);

线程函数 UseComputer 使用上述 Semaphore 对象实现线程同步:

static void UseComputer(object UserName)
{
   sp.WaitOne();  // 等待计算机可用
   // 查找可用的计算机......
   // 使用计算机工作......
   sp.Release();  // 不再使用计算机,让出来给其他人使用
}

上述代码被多个线程执行时,“sp.WaitOne()” 语句将信号量 sp 内的资源计数器减 1,只要此计数器不等于 0,说明还有资源可用,则 “sp.WaitOne()” 就不会阻止线程的运行。
由于 sp 的初值等于 3,所以一共有 3 个线程可以同时投入运行,其它的线程只能等待。

当某个线程执行完毕以后,它调用 “sp.Release()” 将信号量的内部计数器加 1,这时,处于阻塞等待队列的第一个线程被唤醒并投入运行。

总之,Semaphore 可以同步多个线程对同一种类型多个共享资源的访问。如果系统中拥有多种不同类型的资源,则可以创建多个 Semaphore 对象。

3.4 线程同步事件类 —— EventWaitHandle

EventWaitHandle 类派生自 WaitHandle,其 Set 方法将自身设置为 signaled 状态,Reset 方法将自身设置为 non-signaled 状态。

public EventWaitHandle(bool initialState, EventResetMode mode);

当允许多个线程投入运行,在 EventWaitHandle 构造函数中传入一个枚举变量:EventResetMode.ManualReset

当只允许一个线程投入运行,在 EventWaitHandle 构造函数中传入一个枚举变量

EventResetMode.AutoReset

为了方便起见,.NET Framework 从 EventWaitHandle 类中又派生出两个子类 ManualResetEvent 与 AutoResetEvent,分别对应上述两种情况。

static void Main(string[] args)
{
   Thread m_objThread = new Thread(__PlayThread);
   m_objThread.IsBackground = true;
   m_objThread.Start();
   while (true)
   {
       ConsoleKey newKey;
       newKey = Console.ReadKey(false).Key;
       if (newKey == ConsoleKey.G)
       {
           Go();
       }
       else if (newKey == ConsoleKey.S)
       {
           Stop();
       }
       else if (newKey == ConsoleKey.B)
       {
           break;
       }
   }
}

static AutoResetEvent are = new AutoResetEvent(false);

static void __PlayThread()
{
   while (true)
   {
       are.WaitOne();  // 等待绿灯
       // TODO...
       Thread.Sleep(1000);
       Console.WriteLine(DateTime.Now);
   }
}

static void Go()
{
   are.Set();   // 点亮绿灯,运行线程
}

static void Stop()
{
   are.Reset(); // 点亮红灯,阻塞线程
}

4 线程池

如果某个应用程序需要频繁地创建大量的线程,由于 CLR 创建和管理一个线程对象需要耗费一定的系统资源,有可能会对程序性能造成较大的影响。

为了解决这个问题,.NET Framework 提供了一个线程池,可以帮我们省去手工创建和管理线程的麻烦,并且能获得较高的性能。

4.1 线程池简介

所谓 “线程池(Thread Pool)”,可以认为它是一个线程容器,系统预先创建好了若干个线程对象放在此容器中,这些线程对象可供重复利用。

Thread 对象的 IsThreadPoolThread 属性用于确定此线程是否来自于线程池。

使用线程池的优势在于它消除了创建和销毁线程对象的开销,可以有效地提高运行效率,因此许多 .NET 技术在内部使用了线程池。以下是几个例子:

当调用委托类型的 BeginInvoke 方法实现异步调用时,就是使用线程池中的线程来执行异步调用的。

4.2 使用线程池

首先需要准备好一个要以多线程执行的方法,此方法必须满足以下的要求:

// 表示线程池要执行回调的方法签名
public delegate void WaitCallback(object state);

然后调用 ThreadPool.QueueUserWorkItem 方法即可将此方法插入到线程池的工作项队列中。当线程池中有 “空闲” 的线程,或者某线程已完成了当前的工作而变得 “空闲”,此 “空闲” 线程就检查工作项队列中是否有工作项,如果有则取出工作项执行。

示例代码如下,使用线程池中的线程计算整数的累加和,其运行结果如下:

static void Main(string[] args)
{
   // 创建工作任务信息对象
   TaskInfo ti = new TaskInfo() { EndNumber = 10000 };
   // 将线程函数加入线程池的等待队列
   ThreadPool.QueueUserWorkItem(CalculateSum, ti);
   are.WaitOne();   // 等待线程池通知工作完成
   Console.WriteLine("从1加到{0}的和为{1}", ti.EndNumber, ti.Sum);
}

static AutoResetEvent are = new AutoResetEvent(false);

static void CalculateSum(object argu)
{
   TaskInfo ti = argu as TaskInfo;
   ti.Sum = 0;
   for (int i = 1; i <= ti.EndNumber; i++)
   {
       ti.Sum += i;
   }
   are.Set();   // 通知主线程工作完成
}

class TaskInfo
{
   public int EndNumber = 0; // 要累加到的数
   public int Sum = 0; // 累加结果
}

第十一章 线程同步与并发访问共享资源_第2张图片

注:

(1)调用 ThreadPool.QueueUserWorkItem 方法只能将线程函数加入线程池的等待队列,并不能保证它马上得到执行。如果线程中的线程都很忙,可能会等待较长时间才得到结果。

(2)线程池中的线程都是背景线程,因此如果进程很快就退出了,则线程函数有可能得不到执行得机会。

5 线程局部存储区

每当创建一个线程时,操作系统 除了为每个线程创建相关的核心对象之外,还为每个线程分配了一个专供此线程访问的存储区,称为 “线程局部存储区(TLS)”。

5.1 深入了解线程局部存储区

TLS 是保存于进程的环境块中,是供线程使用的专用存储区。下图展示了位于进程地址空间中的线程局部存储区的相关数据结构。

第十一章 线程同步与并发访问共享资源_第3张图片

操作系统为每个进程都分配了一个大小为 TLS_MINIMUM_AVAILABLE 的数组(简称 TLS 数组),在 .NET 平台上,常量 TLS_MINIMUM_AVAILABLE 的默认值为 64。

数组中的元素称为 “槽(Slot)”,在槽中存放的是内存块地址指针。

初始时,TLS 数组中的所有元素都是 “未使用(FREE)”的,当一个线程被创建时,如果它需要使用 TLS,那么,它需要在启动时调用 Win32 API 函数 TlsAlloc 获取一个 TLS 数组中的可用槽的索引,然后,线程申请为自己分配用于保存数据的内存块,再调用 Win32 API 函数 TlsSetValue 将内存块首地址保存到 TLS 槽中,这个槽的索引值就是 TlsAlloc 函数的返回值。

当线程需要读取数据时,它需要调用 Win32 API 函数 TlsGetValue,传给它一个 TLS 数组的索引值,从而从指定槽中获取数据。

当线程不再需要使用 TLS,或者线程运行结束时,它需要调用 Win32 API 函数 TlsFree 将指定索引的 TLS 数组元素重置为 “未使用(FREE)”状态。

这就是非托管 Windows 应用程序中使用线程局部存储区的基本原理。在托管的 .NET 应用程序中,上述工作得到了大大地简化,软件工程师不再需要理会操作系统底层技术细节就可以使用线程局部存储区。

下面介绍 .NET 使用线程局部存储区的几种方式。

5.2 线程相关的静态字段

当给一个类的静态字段添加一个 [ThreadStaticAttribute] 标记时(可以简写为 “[ThreadStatic]”),这个字段称为 “线程相关的静态字段(Thread-Relative Static Field)”。这个静态字段将保存于 TLS 中,每个线程对它们的存取都是独立的。

如下示例程序展示了线程相关静态字段的用法。此示例程序使用 TLS 保存当前线程 ID 和创建的时间。

在 ThreadData 类中定义了两个线程相关静态字段,用于保存线程 ID 和线程的创建时间。

class ThreadData
{
   [ThreadStatic]
   static int ThreadID;
   [ThreadStatic]
   static DateTime CreateTime;

   // 线程函数
   public static void ThreadStaticDemo()
   {
       //
       ThreadID = Thread.CurrentThread.ManagedThreadId;
       CreateTime = DateTime.Now;
       // TODO...
   }
}

在线程函数 ThreadStaticDemo 中可以自由地访问这两个线程相关静态字段,无需加锁,因为每个线程都会有这两个字段的不同拷贝。

例程一:

class Program
{
   //[ThreadStatic]
   static int value;

   static void Main(string[] args)
   {
       Thread m_Thread = new Thread(__PlayThread);
       m_Thread.Start();
       m_Thread.IsBackground = false;
       Console.WriteLine("正在计算,请耐心等待...");
       m_Thread = new Thread(__PlayThread);
       m_Thread.Start();
       m_Thread.IsBackground = false;
   }

   static void __PlayThread()
   {
       Console.WriteLine(Thread.CurrentThread.ManagedThreadId.ToString());
       value = Thread.CurrentThread.ManagedThreadId * 2;
       Thread.Sleep(1000);
       Console.WriteLine(value.ToString());
   }
}

第十一章 线程同步与并发访问共享资源_第4张图片

[ThreadStatic]     // 标记为线程相关的静态字段
static int value;

这个静态字段将保存于 TLS 中,每个线程对它们的存取都是独立的。

第十一章 线程同步与并发访问共享资源_第5张图片

线程同步方法小结

本章围绕着 "线程同步" 于 "并发访问共享资源" 这两个多线程开发中的核心问题而展开。

  • Monitor 主要用于给共享资源加锁,以保证一次只有一个线程访问共享资源。C#提供的 lock关键字简化了代码,编译器会将 lock 关键字转换为对 Monitor 类相应方法的调用。 .NET 4.0提供了一个新的 SpinLock 结构,主要用于线程占用锁的时间不长以及需要频繁申请锁的开发场景中,性能优于 lock 关键字。
  • Mutex 与 Monitor 类似,但 Monitor 只能给引用类型的变量加锁,而 Mutex 可以用于同步值类型变量,而且使用 Mutex.WaitAny 和 Mutex.WaitAll 方法可以一次等待多个 Mutex 对象,而 Monitor 无法做到这点。
  • Semaphore 用于同步多个线程访问同一类型的多个共享资源,即一次允许不超过共享资源总数的线程投入运行。
  • EventHandle 及其子类 AutoResetEvetn 和 ManualResetEvent 是最接近 “交通信号灯” 功能的同步方法,可以让线程走走停停。
  • CountDownEvent 主要用于让某个线程等待多个线程都完成工作。
  • ThreadPool (线程池)提供了有限的线程同步手段,但由于它避免了创建和销毁线程的开销,因而在性能上占优。
  • 使用线程局部存储区(TLS),既可以充分利用多线程的好处,又避免了处理复杂的线程同步情况,应用得当可以得到较高的性能。主要是使用静态标记 [ThreadStatic] 来标记变量,保证每个线程都会访问独立的变量。

 

你可能感兴趣的:(.NET,开发要点精讲)