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

同步基础和同步包装器的概念

starIconstarIcon
emptyStarIcon
starIcon
emptyStarIconemptyStarIcon

2.69/5 (6投票s)

2005年2月21日

6分钟阅读

viewsIcon

54254

downloadIcon

352

为防止对数据结构的并发访问,一种方法是使用线程感知的对象,另一种方法是为对象创建线程安全的包装器。

引言

多线程应用程序提供了许多活动正在或多或少同时发生的错觉。在 C# 中,System.Threading 命名空间提供了一系列支持多线程编程的类型。

并发是多个线程访问共享数据时的关键概念之一。不受控制的并发访问可能会使对象处于不确定状态,从而导致运行时异常,或者对象会表现出异常行为并生成随机的、无用的输出。

在三种情况下,共享对象不需要同步。

  • 如果对象已被写入但从未读取。
  • 如果对象已被读取但从未写入。
  • 如果任何时候只有一个线程访问该对象。

不属于以上条件之一的对象(以及大多数实际对象都不会,天哪,谁想拥有一个写入但从不读取的对象,反之亦然)应该进行彻底分析并在多线程环境中使用时进行适当的同步。

本文目的

让我们假设您设计了一个本应在单线程环境中运行的“线程笨拙”对象。然后,突然,您的项目规范发生了变化(请记住,客户——以及您的老板——永远是正确的:)。现在,您可怜的无辜对象发现自己在多线程环境中 unprotected。

本文讨论了“线程笨拙”对象在多线程环境中可能发生的情况,并提出了一种方法(例如使用同步包装器)在不大幅更改其内部结构的情况下,安全地在多线程环境中使用它。

何时使用同步?

对象的同步是通过 Monitor 实现的,而监控会给应用程序带来开销。因此,如果您的对象不会在多线程环境中执行,使用**线程不感知**对象将比其**线程感知**的对应对象更有效。

我想稍微扩展一下线程感知的概念

线程感知和线程安全

大多数时候,人们互换使用线程感知/线程安全这两个词。然而,它们之间有细微的差别。尽管我对此主题进行了彻底的 Google 搜索,但未能找到明确的书面定义。人们似乎有自己的主观解释。在阅读了关于该主题的各种定义之后,以下是我对它们的总结定义:(我乐于接受并感谢对这些术语的任何贡献)

线程感知

在任何给定时间,最多一个线程可以激活在该对象上。该对象感知周围的线程,并通过将所有线程放入队列来保护自己免受线程的侵害。由于任何时候只有一个线程可以激活在该对象上,因此该对象将始终保持其状态。不会有任何同步问题。

线程安全

在给定时间,多个线程可以激活在该对象上。该对象知道如何处理它们。它已正确同步对其共享资源的访问。它可以在此多线程环境中保持其状态数据(即,它不会 fall into intermediate and/or indeterminate states)。在此多线程环境中使用此对象是安全的。

使用既非线程感知也非线程安全的aught 任何对象都可能导致获得**不正确**和**随机数据**以及神秘的**异常**(由于当第二个线程访问该对象时,它正在被另一个线程使用并且处于不稳定、中间状态)。

一个简单的接口

让我们从创建一个简单的接口开始

namespace com.sarmal.articles.en.synchronization
{
    using System;
    public interface BankAccount 
    {
        void Empty();
        void Add(double money);
        double Balance {get;}
        bool IsSynchronized {get;}
        object SyncRoot {get;}
    }
}

没什么好惊讶的,对吧?Empty() 方法清空余额,Add(double) 将钱添加到银行账户,而 Balance 是当前存入账户的总金额。

需要更多关注的另外两个元素是 IsSynchronizedSyncRootIsSynchronized 方法返回对象是否对多线程访问安全,而 SyncRoot 是对象的同步根,您可以将其作为参数传递给 lock 语句。

lock(acc.SyncRoot) {
    ... critical code goes here ...
}

实现类

接口的实现并不是什么大问题。有一个私有的 double 成员变量,Add 方法向其添加,Empty 将其设置为零,而 Balance 返回当前存储在该变量中的值。

值得注意的是 Add 方法

public virtual void Add(double money) {
    double temp = sum;
    Thread.Sleep(0);
    temp += money;
    sum = temp;
}

请注意,这四行代码相当于 sum+=money。我们将语句分成多行,并在中间添加了一个 Thread sleep 以增加出现与并发相关的错误的概率。Add(double money) {sum+=money;} 也可以达到本文的目的,但上面提供的代码会导致更戏剧性和更明显的结果。

重写的同步方法

实现类 AccountImpl 中引起注意的地方是两个重写的 Synchronized 方法

public static BankAccountImpl Synchronized(BankAccountImpl impl) {
    return (BankAccountImpl) Wrap(impl);
}

public static BankAccount Synchronized(BankAccount acc) {
    return (BankAccount) Wrap(acc);
}

private 方法 Wrap 如果参数是同步的,则返回传递的 BankAccount 对象本身,否则返回一个同步的包装器类 SyncAccount,它扩展了 BankAccountImpl

包装器类 SyncAccount 是本文讨论的关键点,它是一个 private sealed 内部类。它将 BankAccount 的引用存储为 private 成员,并使用成员的 SyncRoot 锁定代码的关键部分。

private sealed class SyncAccount:BankAccountImpl {
    private object syncRoot;
    private BankAccount bankAccount;

    public SyncAccount(BankAccount acc) {
        bankAccount = acc;
        syncRoot = acc.SyncRoot;
    }
    
    public override void Empty() {
        lock(syncRoot) {
            bankAccount.Empty();
        }
    }
    
    ... truncated ...
    
    public override bool IsSynchronized {get {return true;}}
    public override object SyncRoot {get {return syncRoot;}}
}

测试用例

Test.cs 包含用于测试应用程序的 Test 类。如果您打开它,您将在其 main 方法中看到一些注释掉的代码。

acc = new BankAccountImpl();
//acc = BankAccountImpl.Synchronized(acc);

构建项目并运行,它将生成如下类似的输出

Total balance is expected to be:  120.
Starting Thread-0
Starting Thread-1
Starting Thread-2
Thread-0 entered add.
Thread-0: Balance before add : 0

... truncated ...

Starting Thread-4
Thread-0: Balance after add : 4
Thread-0: Balance before add : 4
Thread-1: Balance after add : 4
Thread-1: Balance before add : 4
Thread-0: Balance after add : 5
Thread-0: Balance before add : 5
Thread-1: Balance after add : 5
Thread-1: Balance before add : 5

... truncated ...

Thread-9: Balance after add : 30
Thread-9 exited add.
Thread-11: Balance after add : 30
Thread-11: Balance before add : 30
Joining Thread-10
Joining Thread-11
Thread-11: Balance after add : 31
Thread-11 exited add.
The current balance is 31.

每次运行的结果都会不同,但趋势是相同的。当前余额将始终小于预期余额。

这种情况有点类似于经典的生产者-消费者困境(反向)。在某个时间点,多个线程读取(即消耗)共享变量。线程将它们读取的值(即,不是原始值而是它们捕获的快照)增加一,然后将其存储回去(即,生产)。

现在让我们取消注释掉的代码,然后重新构建解决方案。

acc = new BankAccountImpl();
acc = BankAccountImpl.Synchronized(acc);

我们将获得一个围绕该类的同步包装器,一切都会如预期进行。任何时候只有一个线程能够访问每个方法,这将确保数据完整性。

以下是重新构建后结果的样子

Total balance is expected to be:  120.
Starting Thread-0
Starting Thread-1
Starting Thread-2
Thread-1 entered add.
Thread-1: Balance before add : 0
Thread-1: Balance after add : 1
Thread-1: Balance before add : 1
Starting Thread-3
Thread-1: Balance after add : 2
Thread-1: Balance before add : 2
Thread-1: Balance after add : 3
Thread-1: Balance before add : 3
Thread-0 entered add.
Thread-2 entered add.
Thread-0: Balance before add : 4
Thread-0: Balance after add : 5
Thread-0: Balance before add : 5
Starting Thread-4
Thread-0: Balance after add : 6
Thread-0: Balance before add : 6
Starting Thread-5
Thread-0: Balance after add : 7
Thread-0: Balance before add : 7

... truncated ...

Joining Thread-5
Joining Thread-6
Joining Thread-7
Joining Thread-8
Joining Thread-9
Joining Thread-10
Joining Thread-11
The current balance is 120.

结论

同步是维护多线程环境中数据并发性的重要因素。无论您使用何种语言编程,是 ANSI C、C#、Java 还是其他任何语言,如果存在多个进程争夺共享资源的控制权,对情况进行仔细分析是极其必要的。现实世界的多线程场景并不像上述 Account 示例那样简单。但是,.NET 框架使得线程和同步易于处理。说实话, IMHO,C# 的线程能力胜过 Java。

接下来呢?

我选择通过一个示例应用程序来讨论基本问题,而不是深入探讨线程的细节。我将详细检查 System.Threading 命名空间的方法,以及缓存、内存模型、内存屏障、惰性初始化等高级问题,以及死锁、原子性、线程安全、竞态条件、信号量、互斥锁、临界区等概念性主题,依此类推……留待我后续的文章。否则,我会很无聊,并且会占用太多纸张空间。

祝您编码愉快!

历史

  • 2005-02-21
    • 文章已创建。
  • 2005-02-24
    • 添加了一些描述性文本,修改了代码(添加了一些额外的控制台打印以描述正在发生的事情)。
    • 创建了 XML 文档
    • 上传了修订后的演示项目。
  • 2005-02-26
    • 根据修订后的代码修改了文章。
© . All rights reserved.