借助 STM.NET 处理 ACID 事务
Ted Neward
本专栏专门介绍编程语言,但您会发现有时语言理念不需要直接进行修改就可以转换成其他语言,这一点很有意思。
Microsoft Research 语言 C-Omega 就是这样的一个例子,该语言有时简写为 Cw,因为希腊语 Omega 符号看起来很像 US 键盘布局上的小写字母 w。C-Omega 除引入了许多数据统一和代码统一概念(可最终作为 LINQ 转换为 C# 和 Visual Basic 语言)之外,还提供了一种新的名为“chords”的并发方法,可将 C-Omega 保存到 Joins 库中。虽然在撰写本文时,Joins 产品尚未推出,但整个 chords 并发概念可通过某个库进行提供,这意味着任何普通的 C# 或 Visual Basic(或其他 .NET 语言)程序都可以利用此概念。
除了此概念外,还提供了一个 Code Contracts 工具,可通过 Microsoft DevLabs 网站下载,有关内容在 2009 年 8 月出版的《MSDN 杂志》中进行了介绍。契约式设计是一种语言功能,在 Eiffel 等语言中起到了显著作用,该功能最初通过 Microsoft Research 语言 Spec# 应用于 .NET。类似类型的契约式保证系统也是通过 Microsoft Research 引入的,其中包括我最喜欢的产品之一 Fugue,它利用自定义属性和静态分析检查客户端代码是否正确。
再次重申,尽管 Code Contracts 尚未作为正式产品发布,也没有获得允许在生产软件中使用的许可证,但它作为一种库而不是一种独立的语言存在,本身就意味两层含义。首先,在理论上,Code Contracts 可由任何 .NET 开发人员作为库编写,这足够说明具有类似的功能。其次,该产品提到的功能(假定确实具有这些功能)可用于各种语言,包括 C# 和 Visual Basic。
如果您嗅到了一丝主题的气息,那么您猜对了!本月,我要重点介绍一个最近发布的源于多语言世界的库:软件事务内存或 STM。STM.NET 库可通过 DevLabs 网站下载,但与我提到的其他一些实现大相径庭的是,它不是一个独立的库,不可以链接到程序或作为静态分析工具运行,从整体看,它是 .NET 基类库的一个替代和补充等。
但是请注意,STM.NET 的当前实现与当前 Visual Studio 2010 Beta 的兼容性不是很理想,所以在这种情况下,您所关心的有关在计算机上安装未完成的/Beta/CTP 软件的常见免责声明,一定要加倍注意。该产品应该与 Visual Studio 2008 一起安装,但我仍不会将其安装在您的工作机上。下面是另一个示例,其中 Virtual PC 是您主要使用的工具。
入门
虽然 STM.NET 综合了多种不同的语言,但 STM 的概念非常直观并易于理解:并非强制开发人员重点研究实现操作并发的方法,如锁定等,而是允许他们在具备特定的支持并发特性时标记应该执行哪部分代码,并在必要时使用语言工具(编译器或解释器)管理锁定。换句话说,STM.NET 与数据库管理员和用户的性质一样,可使程序员使用 ACID 样式的事务性语义标记代码,并将管理锁定的单调工作留给基础环境。
虽然 STM.NET 可能看起来只是管理并发的另一种尝试,但实际上 STM 的作用远不止这些,它尝试将数据库 ACID 事务的全部四种特质引入内存编程模型。除了代表程序员对锁定进行管理外,STM 模型还提供了原子性、一致性、隔离和持续性,无论同时存在多个执行线程,单凭这些特性就可使编程更简单。
下面以伪代码(已屡见不鲜)为例,请注意这种情况:
BankTransfer(Account from, Account to, int amount) {
from.Debit(amount);
to.Credit(amount);
}
如果 Credit 失败并引发一个异常,将出现什么情况?如果来源帐户的借方仍有记录,而贷方却没有相应进项,显然用户会很不乐意,此时开发人员就得去补救了:
BankTransfer(Account from, Account to, int amount) {
int originalFromAmount = from.Amount;
int originalToAmount = to.Amount;
try {
from.Debit(amount);
to.Credit(amount);
}
catch (Exception x) {
from.Amount = originalFromAmount;
to.Amount = originalToAmount;
}
}
这乍一看好像负面影响很大。但是请记住,根据对 Debit 和 Credit 方法的精确实现程度,在 Debit 操作完成之前或 Credit 操作完成之后(但没有结束)可能会引发异常。这就意味着 BankTransfer 方法必须确保:在此操作中引用和使用的所有数据都可以回到操作开始时的准确状态。
如果 BankTransfer 变得更复杂,例如,同时对三个或四个数据项执行操作,则 catch 块中的恢复代码会立刻变得非常混乱。而且,我不得不承认此模式的出现频率非常高。
需要注意的另一个方面是隔离。在原始代码中,另一个线程如果在执行结余操作时进行读取,则读取的余额可能不正确且至少损坏一个帐户。此外,如果只是对结余操作进行锁定,而 from/to 对有时顺序无常,则可能遇到死锁情况。STM 不使用锁定,就可以对结余操作进行保护。
但是,如果该语言提供某种类型的事务性操作,例如,可以实际处理锁定和失败/回滚的原子性关键字,就像 BEGIN TRANSACTION/COMMIT 可以对数据库执行的操作一样,则编写 BankTransfer 示例的代码会变得非常简单,如下所示:
BankTransfer(Account from, Account to, int amount) {
atomic {
from.Debit(amount);
to.Credit(amount);
}
}
您不得不承认,这解决了很多麻烦。
但是,基于库的 STM.NET 方法不会提供多少事务性操作,因为 C# 语言不允许这种灵活程度的语法。而您可以使用以下代码行解决一些问题:
public static void Transfer(
BankAccount from, BankAccount to, int amount) {
Atomic.Do(() => {
// Be optimistic, credit the beneficiary first
to.ModifyBalance(amount);
from.ModifyBalance(-amount);
});
}
深入分析:这确实是语言的一大变革
遗憾的是,在重读 Neward 的专栏时,我突然意识到我对它产生了基本误解。Neward 尝试将语言扩展划分为两种:需要进行语言更改的扩展和完全属于库更改的扩展。该专栏尝试将 STM.NET 划分为后者(仅限于库更改),但我坚决认为这是错误的。
仅限于库更改的扩展是可以完全在现有语言中实现的一种扩展。基于库的 STM 系统确实存在;在这些系统中,通常要求将应采用事务性语义的数据声明为某种特殊类型,例如“TransactionalInt”。STM.NET 则不同,它可以为普通数据透明地提供事务性语义,只是因为它本身能够在动态事务范围内进行访问。
这要求对在事务中执行的代码进行的每次读写操作进行修改,以便进行其他要求必要锁定的关联调用、创建和填充卷影副本等。在实现时,我们大范围地修改了 CLR 的 JIT 编译器,以生成要在事务中执行的差别迥异的代码。原子性关键字(即使我们是通过基于委托的 API 提供的也是一样)从根本上更改了语言语义。
因此,我认为我们确实改变了语言。在 C# 等 .NET 语言中,语言语义通过两个方面才可以实现:源代码级语言编译器以及该编译器对本身声明的 MSIL 的语义的假设 — CLR 运行时执行 IL 的方式。我们从根本上改变了 CLR 对字节码的解释,所以我认为这改变了语言。
请特别注意,假设 CLR 的 JIT 编译器遇到以下代码:
try {<br xmlns="http://www.w3.org/1999/xhtml" /> <body> <br xmlns="http://www.w3.org/1999/xhtml" />} <br xmlns="http://www.w3.org/1999/xhtml" />catch (AtomicMarkerException) {}
需要动态修改 <body> 中的代码(在其调用的方法中递归执行)才能确保事务性语义。我应该强调的是,这与处理异常绝对没有任何关系 — 这只是一个用于识别原子块的技巧,因为 try/catch 结构是 IL 中唯一可用于识别词义块的机制。从长远来看,我们希望 IL 语言中出现一些更类似于显式“原子”块的内容。为此,基于委托的接口应运而生。
总之,IL 级原子块虽然已表达,但确实从根本上更改了在其中运行的代码的语义。这就是为什么 STM.NET 包含一个新的、进行大量修改的 CLR 运行时而不只是更改 BCL 的原因。如果您采用了存储 CLR 运行时并将其与 BCL 从 STM.NET 中一起运行,则不会获得事务性语义(事实上,我怀疑它根本就不能运行)。
—Dave Detlefs 博士,Microsoft 公共语言运行时架构师
语法不像原子性关键字那样简洁,但 C# 可以通过匿名方法来捕获用于构成所需原子块的主体的代码块,因此它可以在相似类型的语义下执行。(非常遗憾,直至撰写本文时,STM.NET 的初步成果仅可支持 C#。它不能在所有语言中使用并不是由于技术原因,而是因为 STM.NET 团队为首次发行只重点研究了 C#。)
STM.NET 入门
您首先需要从 DevLabs 网站下载 Microsoft .NET Framework 4 Beta 1,才能使用 Software Transactional Memory V1.0 内部测试版,该名称过于繁琐,我简称为 STM.NET BCL 或 STM.NET。登录该网站后,还要下载 STM.NET 文档和示例。前者是真正的 BCL 和 STM.NET 工具及补充程序集,后者包含一个用于构建 STM.Net 应用程序的 Visual Studio 2008 模板,其中提供了一个文档和大量示例项目。
创建新的启用 STM.NET 的应用程序与任何其他应用程序一样,首先打开“新建项目”对话框(请参见图 1)。选择 TMConsoleApplication 模板时需要执行几项操作,其中一些不是很直观。例如,直至撰写本文时,对 STM.NET 库执行操作时,.NET 应用程序的 app.config 需要使用以下版本控制技巧:
图 1 使用 TMConsoleApplication 模板开始创建新项目
<?xml version="1.0" encoding="utf-8" ?>
<configuration>
<startup>
<requiredRuntime version="v4.0.20506"/>
</startup>
...
</configuration>
此时将显示其他设置,但需要使用 requiredRuntime 值告知 CLR 启动器填充程序根据运行时的 STM.NET 版本进行绑定。此外,TMConsoleApplication 模板将根据 mscorlib 版本将程序集与 STM.NET 的安装目录中安装的 System.Transactions 程序集绑定在一起,而不是根据存储 .NET Framework 3.0 或 3.5 CLR 附带的版本。必须注意这个问题,因为如果 STM.NET 为您编写的代码之外的任何代码提供事务性访问,则需要使用其自身的 mscorlib 副本。另外,如果它与其他形式的事务(例如轻型事务管理器 (LTM) 提供的轻型事务)正确交互,则还需要具有自身版本的 System.Transactions。
除此之外,STM.NET 应用程序也是一个传统的 .NET 应用程序,使用 C# 编写,编译到 IL 中以及与其余未修改的 .NET 程序集相链接等。与过去十年中出现的 COM+ 和 EnterpriseServices 组件一样,STM.NET 程序集中也会包含更多的扩展,用于描述与 STM.NET 事务性行为进行交互的方法的事务性行为,我会对相应内容及时进行介绍。
你好,STM.NET
由于 2009 年 9 月出版的《MSDN 杂志》中的 Axum 示例,在使用 STM.NET 时,先编写一个传统的 Hello World 应用程序事实上比您最初想象的要难,主要是因为如果您在编写时不涉及事务,则与传统的使用 C# 编写的 Hello World 没有什么两样。如果在编写时利用了 STM.NET 事务行为,则必须考虑将文本写入控制台实际上是一种可以实现的方法(至少 STM.NET 可以办到),这就意味着很难尝试回滚 Console.WriteLine 语句。
所以,我们通过《STM.NET 用户指南》中的一个简单示例来快速演示 STM.NET 内部测试版。在该示例中,MyObject 对象包含两个私有字符串和一个方法,该方法用于将这两个字符串设为某一对值:
class MyObject {
private string m_string1 = "1";
private string m_string2 = "2";
public bool Validate() {
return (m_string1.Equals(m_string2) == false);
}
public void SetStrings(string s1, string s2) {
m_string1 = s1;
Thread.Sleep(1); // simulates some work
m_string2 = s2;
}
}
因为为字段分配参数本身就是一个原子性操作,所以此时不需要考虑并发。但是正如之前显示的 BankAccount 示例,您希望设置两者或者都不进行设置,并且在设置操作期间不希望显示部分更新(一个字符串正在设置,而另一个没有)。您将生成两个线程,对这两个字符串盲目地重复设置,而第三个线程用来验证 MyObject 实例的内容,如果事件 Validate 返回 false 则报告一个冲突(请参见图 2)。
图 2 手动验证对 MyObject 的原子性更新
[AtomicNotSupported]
static void Main(string[] args) {
MyObject obj = new MyObject();
int completionCounter = 0; int iterations = 1000;
bool violations = false;
Thread t1 = new Thread(new ThreadStart(delegate {
for (int i = 0; i < iterations; i++)
obj.SetStrings("Hello", "World");
completionCounter++;
}));
Thread t2 = new Thread(new ThreadStart(delegate {
for (int i = 0; i < iterations; i++)
obj.SetStrings("World", "Hello");
completionCounter++;
}));
Thread t3 = new Thread(new ThreadStart(delegate {
while (completionCounter < 2) {
if (!obj.Validate()) {
Console.WriteLine("Violation!");
violations = true;
}
}
}));
t1.Start(); t2.Start(); t3.Start();
while (completionCounter < 2)
Thread.Sleep(1000);
Console.WriteLine("Violations: " + violations);
...
请注意此示例的构建方式,如果将 obj 中的两个字符串设置为同一值,则验证失败,说明线程 t1 的 SetStrings(“Hello”、“World”)进行了部分更新(使第一个“Hello”与 t2 设置的第二个“Hello”相匹配)。
粗略看一下 SetStrings 的实现过程,则会发现此代码很难达到线程安全。如果中途发生线程切换(可能会调用 Thread.Sleep,这会导致当前正在执行的线程放弃其时间片),则另一线程会轻易地再次跳到 SetStrings 的中间位置,使 MyObject 实例处于无效状态。运行该实现,经过足够的迭代后,开始出现冲突。(在便携式计算机上,我必须运行该实现两次才会显示冲突,这充分证明了:运行一次没有发生错误并不意味着该代码不存在并发错误。)
要修改此代码以使用 STM.NET,只需要对 MyObject 类进行很小的更改,如图 3 所示。
图 3 使用 STM.NET 验证 MyObject
class MyObject {
private string m_string1 = "1";
private string m_string2 = "2";
public bool Validate() {
bool result = false;
Atomic.Do(() => {
result = (m_string1.Equals(m_string2) == false);
});
return result;
}
public void SetStrings(string s1, string s2) {
Atomic.Do(() => {
m_string1 = s1;
Thread.Sleep(1); // simulates some work
m_string2 = s2;
});
}
}
您可以看到,唯一需要修改的地方就是使用 Atomic.Do 操作将 Validate 和 SetStrings 的主体封装到原子性方法中。现在运行就不会显示冲突了。
事务性关联
善于观察的读者可能已经发现了图 2 中 Main 方法顶部的 [AtomicNotSupported] 属性,可能想了解它的用途,或更想知道它是不是与 COM+ 提供的属性的作用一样。事实证明,确实是这样:STM.NET 环境需要一些支持,才可以了解在运行原子块时调用的方法是否具有事务性,以便它才可以为这些方法提供必要和所需的支持。
当前的 STM.NET 版本中可以提供以下三种属性:
- AtomicSupported — 程序集、方法、字段或委托支持事务性行为,可在原子块内外成功使用。
- AtomicNotSupported — 程序集、方法或委托不支持事务性行为,因此不应在原子块内部使用。
- AtomicRequired — 程序集、方法、字段或委托不光支持事务性行为,仅应在原子块内不使用(这可以保证始终在事务性语义下使用此项目)。
从技术角度讲,还有第四个属性 AtomicUnchecked,它可以向 STM.NET 传达在某个时段内不应检查某个项目。它作为一个应急出口,以避免同时检查所有代码。
在尝试运行以下简单代码时,AtomicNotSupported 属性将导致 STM.NET 系统引发 AtomicContractViolationException:
[AtomicNotSupported]
static void Main(string[] args) {
Atomic.Do( () => {
Console.WriteLine("Howdy, world!");
});
System.Console.WriteLine("Simulation done");
}
因为没有使用 AtomicSupported 对 System.Console.WriteLine 方法进行标记,所以当 Atomic.Do 遇到原子块中的调用时会引发异常。此安全措施可确保在原子块内仅执行具事务性的方法,并为代码提供其他安全保护。
你好,STM.NET(第二部分)
如果您一定要编写传统的 Hello World,应该怎么办?如果您非常希望在执行其他两个事务性操作的同时在控制台上显示一行文字(或写入文件,或执行一些其他非事务性行为),但仅在这两个操作都成功时才可以显示,您应该怎么办?STM.NET 提供了三种方法可以解决此问题。
第一种方法,通过将代码置于传递到 Atomic.DoAfterCommit 的块中,在事务之外(而且仅在事务提交后)执行非事务性操作。因为该块中的代码通常要使用从事务内生成或修改的数据,所以 DoAfterCommit 将从事务内传递到该代码块的上下文参数作为其唯一的参数。
第二种方法,您可以通过调用 Atomic.DoWithCompensation 创建一个补偿操作,以在事务最终失败时执行,其此方法也会使用上下文参数将数据从事务内部封送到提交或补偿代码块(根据需要)。
第三种方法,您可以执行与上面完全相同的操作并创建事务性资源管理器 (RM),以了解如何使用 STM.NET 事务性系统。创建过程实际上比看上去简单 — 只需继承 STM.NET 类 TransactionalOperation,它包含 OnCommit 和 OnAbort 方法,您可对这两种方法进行覆盖以分别提供适当的行为。使用此新类型的 RM 时,首先使用它调用 OnOperation,从而有效地使资源参与到 STM.NET 事务中。如果其他操作失败,则对其调用 FailOperation。
因此,如果要事务性写入某个基于文本的流,您可以编写一个添加文本的资源管理器,如图 4 所示。这样,您就可以在原子块内通过 TxAppender 写入某个文本流,事实上这要求您借助 [Atomic-Required] 属性来完成(请参见图 5)。
图 4 事务性资源管理器
public class TxAppender : TransactionalOperation {
private TextWriter m_tw;
private List<string> m_lines;
public TxAppender(TextWriter tw) : base() {
m_tw = tw;
m_lines = new List<string>();
}
// This is the only supported public method
[AtomicRequired]
public void Append(string line) {
OnOperation();
try {
m_lines.Add(line);
}
catch (Exception e) {
FailOperation();
throw e;
}
}
protected override void OnCommit() {
foreach (string line in m_lines) {
m_tw.WriteLine(line);
}
m_lines = new List<string>();
}
protected override void OnAbort() {
m_lines.Clear();
}
}
图 5 使用 TxAppender
public static void Test13() {
TxAppender tracer =
new TxAppender(Console.Out);
Console.WriteLine(
"Before transactions. m_balance= " +
m_balance);
Atomic.Do(delegate() {
tracer.Append("Append 1: " + m_balance);
m_balance = m_balance + 1;
tracer.Append("Append 2: " + m_balance);
});
Console.WriteLine(
"After transactions. m_balance= "
+ m_balance);
Atomic.Do(delegate() {
tracer.Append("Append 1: " + m_balance);
m_balance = m_balance + 1;
tracer.Append("Append 2: " + m_balance);
});
Console.WriteLine(
"After transactions. m_balance= "
+ m_balance);
}
很明显,这个操作需要的时间比较长,所以仅适用于特定的情形。对于一些媒体类型,此操作可能会失败,但对于大多数媒体类型,如果所有实际不可逆行为延迟到 OnCommit 方法,则此操作足以满足您大部分进程内的事务性需求。
使用 STM.NET
使用 STM 系统需要一小段适应过程,但一旦习惯了,您就离不开它了。想想哪些可能的情况使用 STM.NET 可以简化编码。
当与其他事务处理资源一起使用时,STM.NET 可快速、轻松地集成到现有事务处理系统中,使 Atomic.Do 成为系统中唯一的事务处理的代码源。STM.NET 示例在 TraditionalTransactions 示例中对此进行了演示,即向 MSMQ 专用队列发布消息并声明当原子块失败时不会向该队列发布任何消息。这种用法可能最显而易见。
在对话框中(尤甚是多步骤向导进程对话框或设置对话框),用户点击“取消”按钮就可使对设置或对话框数据元素的更改回到更改前的状态,这一功能非常有用。
单元测试(如 NUnit、MSTest 和其他系统)将尽最大努力来确保:如果测试编写正确,不会将某个测试的结果泄露给下一个测试。如果 STM.NET 达到生产状态,NUnit 和 MSTest 可以重构其测试案例执行代码以使用 STM 事务将测试结果相互分隔开来,这将在每个测试方法结束时生成一个回滚,从而清除测试可能生成的任何更改。此外,在执行测试时,在 AtomicUnsupported 方法之外调用的任何测试都将标记为错误,而不是将测试结果自行泄露给测试环境外的媒体(例如磁盘或数据库)。
STM.NET 还可以用于实现域对象属性。虽然大多数域对象的某些属性非常简单,可分配给字段或返回该字段的值,但对于使用多步算法的更复杂的属性会面临多个线程导致的风险:显示部分更新(当其他线程调用正在设置的属性)或虚构的更新(如果其他线程调用正在设置的属性,由于某种形式的验证错误,最终会放弃原始更新)。
更有趣的是,Microsoft 外的研究人员正在研究将事务扩展到硬件,以便将来可以由内存芯片本身对更新对象的字段或本地变量提供硬件级别的事务性保护,与如今的方法相比,事务可以飞速发展。
但是,对于 Axum,Microsoft 依靠您的反馈来决定此技术是否值得继续研究和全面推广,所以如果您对这个想法感兴趣,或者其中缺少一些对您的编码实践很重要的内容,请告知 Microsoft,他们很乐意倾听您的建议。
Ted Neward 是 Neward and Associates 的负责人,这是一家专门研究 .NET 和 Java 企业系统的独立公司。他编写了多本著作,还是 Microsoft MVP 架构师、INETA 讲师和 PluralSight 培训师。您可以通过 ted@tedneward.com 与 Ted 联系,或通过 <blogs.tedneward.com> 访问其博客。
衷心感谢以下技术专家对本文的审阅: Dave Detlefs 和 Dana Groff