C#线程同步(三)线程安全

目录

1.总纲

2.线程安全与.net类型

[2.1 静态成员](#2.1 静态成员)

[2.2 只读线程安全](#2.2 只读线程安全)

3.应用服务中的线程安全

4.带UI的应用与线程管理

5.不可变对象


1.总纲

当程序或方法在任何多线程场景下都能保持确定性行为时,我们称其具备线程安全性。实现线程安全主要依靠锁定机制和减少线程交互的可能性。

通用类型很少会实现完全的线程安全,原因如下:

  • 实现完整线程安全需要巨大的开发成本,特别是当类型包含大量字段时(每个字段在多线程上下文中都可能引发交互问题)

  • 线程安全会带来性能损耗(即使类型未被多线程使用,部分开销仍不可避免)

  • 线程安全的类型并不能保证使用它的程序也具备线程安全性,而后者的实现工作往往使前者的努力变得多余

因此,线程安全通常只在需要处理特定多线程场景时针对性实现。

不过,我们仍有一些"取巧"方法能让大型复杂类在多线程环境中安全运行。其一是通过单一排他锁包裹大段代码(甚至整个对象的访问),以高层级的串行访问为代价降低实现粒度。这种策略在使用非线程安全的第三方代码(或大多数.NET框架类型)的多线程场景中至关重要。关键在于使用相同的排他锁来保护对非线程安全对象所有属性、方法和字段的访问。当对象方法都能快速执行时,这种方案效果良好(否则会导致大量阻塞)

(Ps:除基本类型外,大多数.NET框架类型在实例化后仅支持并发只读访问的线程安全。开发者通常需要借助排他锁来额外实现线程安全(System.Collections.Concurrent中的集合类型例外)。)

另一种取巧方法是通过减少共享数据来最小化线程交互。这种优秀方案被广泛应用于"无状态"的中层应用和网页服务器。由于多个客户端请求可能同时到达,服务器方法必须具备线程安全性。无状态设计(因可扩展性优势而流行)本质上限制了交互可能性,因为类不会在请求间保持数据。此时线程交互仅局限于开发者选择创建的静态字段,例如用于内存缓存常用数据或提供认证审计等基础服务。

实现线程安全的最终方案是采用自动锁定机制。在.NET中可以通过继承ContextBoundObject类并应用Synchronization特性来实现。当调用这类对象的方法或属性时,系统会自动获取对象范围的锁并持续整个执行过程。虽然这减轻了线程安全负担,但会引发新问题:非常规死锁、并发性下降以及意外重入。因此,手动锁定通常是更好选择------至少在当前自动锁定机制还不够完善的情况下。

2.线程安全与.net类型

通过锁定机制,我们可以将非线程安全的代码改造为线程安全的代码。在这方面,.NET Framework 提供了一个很好的范例:几乎所有非基本类型在实例化后都不具备线程安全性(仅支持只读访问的并发操作),但只要通过锁来保护对特定对象的所有访问,它们就能安全地用于多线程环境。 以下示例演示了两个线程如何同时向同一个 List 集合添加元素,然后遍历该列表:

cs 复制代码
class ThreadSafe
{
  static List <string> _list = new List <string>();
 
  static void Main()
  {
    new Thread (AddItem).Start();
    new Thread (AddItem).Start();
  }
 
  static void AddItem()
  {
    lock (_list) _list.Add ("Item " + _list.Count);
 
    string[] items;
    lock (_list) items = _list.ToArray();
    foreach (string s in items) Console.WriteLine (s);
  }
}

在这个例子中,我们使用 _list 对象本身作为锁对象。如果存在两个相关联的列表,我们就需要选择一个公共对象来加锁(可以选择其中一个列表,但更推荐使用独立的专用字段)。 在枚举 .NET 集合时也存在线程安全问题 - 如果在枚举过程中集合被修改,就会抛出异常。本示例没有在整个枚举期间保持锁定,而是先将元素复制到数组中。这种做法可以避免在枚举期间(特别是可能耗时较长的操作时)长时间持有锁。(另一种解决方案是使用读写锁)。

即使是线程安全对象,有时也需要加锁。举例来说,假设 .NET 的 List 类确实是线程安全的,我们要向列表添加新元素:

cs 复制代码
if (!_list.Contains (newItem)) _list.Add (newItem);

无论 List 本身是否线程安全,上面这条语句都不是线程安全的!必须将整个 if 语句包裹在锁中,才能确保在检查元素存在性和执行添加操作之间不会被其他线程打断。而且后续所有修改该列表的操作都需要使用相同的锁。例如,下面这个操作也必须使用相同的锁:

cs 复制代码
_list.Clear();

这样才能确保不会打断之前的操作。换句话说,我们最终还是要像使用非线程安全集合类一样进行加锁(这使得 List 类假设的线程安全性变得多余)。

【在高并发环境下,对集合操作进行加锁可能导致严重的阻塞问题。为此,.NET Framework 4.0 提供了线程安全的队列、堆栈和字典实现。】

2.1 静态成员

通过自定义锁来封装对象访问仅在所有并发线程都知晓并使用该锁时才有效。若对象作用域较广时,这一前提可能不成立。最典型的情况就是公共类型中的静态成员。例如,假设 DateTime 结构体的静态属性 DateTime.Now 不是线程安全的,两个并发调用可能导致输出混乱或异常。采用外部锁定的唯一解决方案可能是在调用 DateTime.Now 前锁定类型本身------lock(typeof(DateTime))------但这需要所有开发人员都遵守该约定(显然不现实)。更何况,锁定类型本身会带来新的问题。

正因如此,DateTime 结构体的静态成员都经过精心设计以确保线程安全。这是贯穿整个 .NET 框架的通用模式:静态成员线程安全,实例成员非线程安全。在为公共类型编写代码时遵循这一模式很有必要,可以避免造成无法解决的线程安全难题。换句话说,通过确保静态方法的线程安全性,你在编写代码时就为该类型的使用者保留了实现线程安全的可能性。但请注意:静态方法的线程安全必须通过显式编码实现,不会因为方法是静态的就自动获得!

2.2 只读线程安全

实现类型的并发只读线程安全(在可行的情况下)具有显著优势,这意味着使用者可以避免不必要的加锁操作 。.NET框架中的许多类型都遵循这一原则:例如集合类型就支持多线程并发读取的线程安全

遵循这一原则的实践很简单:如果将某个类型声明为支持并发只读访问的线程安全类型,那么在使用者预期为只读的方法中,就不应该修改任何字段(若需修改则必须加锁)。例如,在实现集合的ToArray()方法时,开发者可能首先会压缩集合的内部结构。但这种优化会使那些预期该操作为只读的使用者面临线程安全问题。

只读线程安全的设计理念也是.NET将枚举器(IEnumerator)与可枚举集合(IEnumerable)分离的重要原因:两个线程可以同时遍历同一个集合,因为每个线程都会获得独立的枚举器对象。

在没有明确文档说明的情况下,开发者应当谨慎判断方法是否真正具有只读特性。Random类就是个典型案例:当调用Random.Next()方法时,其内部实现需要更新私有种子值。因此,开发者必须在使用Random类时进行加锁,或者为每个线程维护单独的实例。

3.应用服务中的线程安全

应用服务器需要采用多线程架构来处理并发的客户端请求。WCF、ASP.NET 和 Web Services 应用都隐式采用多线程模型,同样适用于使用 TCP 或 HTTP 等网络通道的 Remoting 服务器应用。这意味着在编写服务器端代码时,如果处理客户端请求的线程间可能存在交互,就必须考虑线程安全问题。值得庆幸的是,这种情况较为少见------典型的服务器类要么是无状态的(不包含字段),要么采用为每个客户端或请求创建独立对象实例的激活模型。线程交互通常只通过静态字段发生,这些字段有时用于缓存数据库中的部分数据以提高性能。

举例来说,假设您有一个查询数据库的 RetrieveUser 方法:

cs 复制代码
// User is a custom class with fields for user data
internal User RetrieveUser (int id) { ... }

若该方法被频繁调用,可以通过将结果缓存在静态 Dictionary 中来提升性能。以下是考虑线程安全性的实现方案:

cs 复制代码
static class UserCache
{
  static Dictionary <int, User> _users = new Dictionary <int, User>();
 
  internal static User GetUser (int id)
  {
    User u = null;
 
    lock (_users)
      if (_users.TryGetValue (id, out u))
        return u;
 
    u = RetrieveUser (id);   // Method to retrieve user from database
    lock (_users) _users [id] = u;
    return u;
  }
}

我们至少需要在读写字典时加锁以确保线程安全。本示例在锁定的简单性和性能之间取得了实际平衡。但这种设计存在一个微小的效率缺陷:若两个线程同时使用相同的未检索ID调用该方法,RetrieveUser方法会被重复调用,且字典会执行不必要的更新。虽然在整个方法外加锁可以避免这种情况,但会导致更严重的效率问题------在调用RetrieveUser期间整个缓存都会被锁定,阻塞其他线程检索任何用户数据。

4.带UI的应用与线程管理

无论是Windows Presentation Foundation (WPF) 还是Windows Forms库,都遵循基于线程关联性的模型。尽管实现方式不同,但两者的运行机制非常相似。 富客户端的核心对象在WPF中基于DependencyObject,在Windows Forms中则基于Control类。

这些对象具有线程关联性,意味着只有实例化它们的线程才能访问其成员。违反此规则会导致不可预测行为或抛出异常。 这种设计的优势在于访问UI对象时无需加锁,但劣势在于:若需要调用由其他线程Y创建的对象X的成员,必须将请求封送(marshal)到线程Y。

具体实现方式如下:

  • WPF:调用元素Dispatcher对象的Invoke或BeginInvoke方法
  • Windows Forms:调用控件的Invoke或BeginInvoke方法

Invoke和BeginInvoke都接受委托参数,用于指定目标控件上要执行的方法。Invoke以同步方式工作:调用方会阻塞直到封送完成;BeginInvoke则以异步方式工作:调用方立即返回,封送的请求会被加入队列(使用处理键盘、鼠标和计时器事件的同一消息队列)。 假设我们有一个包含txtMessage文本框的窗口,需要通过工作线程更新其内容,以下是WPF的实现示例:

cs 复制代码
public partial class MyWindow :
 Window
{
  public MyWindow()
  {
    InitializeComponent();
    new Thread (Work).Start();
  }
 
  void Work()
  {
    Thread.Sleep (5000);           // Simulate time-consuming task
    UpdateMessage ("The answer");
  }
 
  void UpdateMessage (string message)
  {
    Action action = () => txtMessage.Text = message;
    Dispatcher.Invoke (action);
  }
}

在winform,类似于:

cs 复制代码
  void UpdateMessage (string message)
  {
    Action action = () => txtMessage.Text = message;
    this.Invoke (action);
  }

【.NET框架提供了两种简化此过程的结构:1. BackgroundWorker组件 2. 任务延续(Task continuations)

工作线程与UI线程的区分

理解富客户端应用程序的线程模型时,可以将其线程划分为两个明确类别:

  • UI线程:负责实例化(并持续"持有")UI元素
  • 工作线程:不持有UI元素,通常用于执行数据获取等耗时操作

大多数富客户端应用采用单UI线程架构(该线程同时作为主应用程序线程),并通过直接创建线程或使用BackgroundWorker来生成工作线程。这些工作线程在执行完毕后,会通过封送机制回到主UI线程来更新控件或报告进度。

何时需要采用多UI线程架构?典型场景是具备多个顶层窗口的应用程序------通常称为单文档界面(SDI)应用程序,例如Microsoft Word。每个SDI窗口在任务栏显示为独立"应用程序",且功能上与其他SDI窗口基本隔离。通过为每个窗口分配独立UI线程,可以显著提升应用程序的响应性。

5.不可变对象

不可变对象是指其状态无法被更改的对象------无论是外部还是内部都不可变。不可变对象中的字段通常声明为只读(readonly),并在构造函数中完成全部初始化。 不可变性是函数式编程的标志性特征------在这种范式中,你不会修改现有对象,而是创建具有不同属性的新对象。LINQ就遵循这一范式。不可变性在多线程环境中也极具价值,因为它通过消除(或最小化)可写状态,避免了共享可变状态的问题。 一种常见模式是使用不可变对象来封装一组相关字段,从而最小化锁的持有时间。举个简单例子,假设我们有以下两个字段:

cs 复制代码
int _percentComplete;
string _statusMessage;

我们希望以原子方式读写这两个字段。与其围绕这些字段加锁,我们可以定义如下不可变类:

cs 复制代码
class ProgressStatus    // Represents progress of some activity
{
  public readonly int PercentComplete;
  public readonly string StatusMessage;
 
  // This class might have many more fields...
 
  public ProgressStatus (int percentComplete, string statusMessage)
  {
    PercentComplete = percentComplete;
    StatusMessage = statusMessage;
  }
}

此时我们可以定义一个该类型的字段,并配合一个锁对象:

cs 复制代码
readonly object _statusLocker = new object();
ProgressStatus _status;

现在我们可以读写该类型的值,而锁的持有时间仅需覆盖单个赋值操作:

cs 复制代码
var status = new ProgressStatus(50, "Working on it");
// 假设这里还要赋值更多字段...
// ...
lock (_statusLocker) _status = status;    // 极短暂的锁

读取该对象时,我们首先在锁内获取对象的副本,之后即可无锁读取其值:

cs 复制代码
ProgressStatus statusCopy;
lock (_statusLocker) statusCopy = _status;   // 同样是非常短暂的锁
int pc = statusCopy.PercentComplete;
string msg = statusCopy.StatusMessage;
...

从技术角度看,最后两行代码的线程安全性是由前一个锁所隐含的内存屏障保证的(参见第四部分)。 需要注意的是,这种无锁方法可以保证相关字段组内部的原子性,但不能防止数据在被读取后发生变化------要实现后者通常仍需加锁。在第五部分,我们将看到更多利用不可变性简化多线程编程的示例(包括PLINQ的应用)。

C#现在专门有个record类型用于方便我们定义不可变对象。在函数式编程中,不可变对象是常常使用的。

相关推荐
BullSmall2 小时前
第三方软件安全-Dependency‑Track Docker 部署
安全·docker·容器
AImatters2 小时前
国产算力底座,支撑油气储运数字化迈过关键分水岭
安全·cpu·算力·海光·油气储运
geovindu3 小时前
CSharp: 万年历
开发语言·后端·c#·.net
KhalilRuan4 小时前
Unity性能优化
unity·性能优化·游戏引擎
叠叠乐5 小时前
中国移动家庭云电脑window关闭所有安全脚本
安全
Jmyd01235 小时前
实训室的 3D 模型涉及肖像文物,数据安全与合规怎么做?
安全·3d·数据安全·虚拟实训
芯盾时代5 小时前
《金融业网络安全管理办法(征求意见稿)》全条款深度拆解(三)
网络·安全·网络安全
敢敢是只喵i6 小时前
一个本地 AI Agent 要操作多个门店或 SaaS 账号,应该怎样安全切换身份?
人工智能·安全·ai·系统架构·业界资讯
Acrel12347 小时前
DC800V 大规模落地,算力机房安全该如何保障
安全