C#12 技术手册(十二)
原文:
zh.annas-archive.org/md5/e2c84fd09097e50aedbc4e5989f32a85译者:飞龙
第十九章:动态编程
第四章 解释了 C# 语言中动态绑定的工作原理。在本章中,我们简要介绍了动态语言运行时(DLR),然后探讨以下动态编程模式:
-
动态成员重载解析
-
自定义绑定(实现动态对象)
-
动态语言互操作性
注意
在 第二十四章 中,我们描述了 dynamic 如何改进 COM 互操作性。
本章中的类型位于 System.Dynamic 命名空间中,除了 CallSite<>,它位于 System.Runtime.CompilerServices 中。
动态语言运行时
C# 依赖于 DLR(动态语言运行时)来执行动态绑定。
尽管名字与 CLR 的动态版本相反,DLR 实际上是位于 CLR 之上的一个库——就像任何其他库,比如 System.Xml.dll。它的主要作用是提供运行时服务来统一静态和动态类型语言的动态编程。因此,诸如 C#、Visual Basic、IronPython 和 IronRuby 等语言都使用相同的调用函数动态协议。这使它们可以共享库并调用其他语言编写的代码。
DLR 还使得在 .NET 中编写新的动态语言相对容易。动态语言作者不需要发出中间语言(IL),而是在表达树的级别上工作(这些表达树与我们在 第八章 中讨论的 System.Linq.Expressions 中的表达树相同)。
DLR 进一步确保所有消费者都能从调用站点缓存中获益,这是一种优化方法,DLR 可以防止重复进行在动态绑定过程中可能昂贵的成员解析决策。
动态成员重载解析
使用动态类型参数调用静态已知方法会将成员重载解析从编译时推迟到运行时。这在简化某些编程任务(例如简化访问者设计模式)中非常有用。它还有助于解决 C# 静态类型强加的限制。
简化访问者模式
本质上,访问者模式允许您在类层次结构中“添加”方法,而无需修改现有类。虽然有用,但与大多数其他设计模式相比,其静态版本显得微妙和不直观。它还要求被访问的类通过暴露 Accept 方法来使其“访问者友好”,如果这些类不在您的控制之下,则可能无法实现。
借助动态绑定,您可以更轻松地实现相同的目标,而无需修改现有的类。为了说明这一点,请考虑以下类层次结构:
class Person
{
public string FirstName { get; set; }
public string LastName { get; set; }
// The Friends collection may contain Customers & Employees:
public readonly IList<Person> Friends = new Collection<Person> ();
}
class Customer : Person { public decimal CreditLimit { get; set; } }
class Employee : Person { public decimal Salary { get; set; } }
假设我们想编写一个方法,程序化地将一个Person的详细信息导出到 XML XElement中。最明显的解决方案是在Person类中编写一个虚方法ToXElement(),返回一个填充了Person属性的XElement。然后我们在Customer和Employee类中重写它,使得XElement还填充了CreditLimit和Salary。然而,这种模式可能存在问题,原因有两个:
-
你可能没有
Person、Customer和Employee类的所有权,因此无法向它们添加方法。(扩展方法也无法提供多态行为。) -
Person、Customer和Employee类可能已经相当庞大。一个常见的反模式是“上帝对象”,其中一个类如Person吸引了大量功能,使得维护变得一团糟。一个良好的解药是避免向Person添加不需要访问Person私有状态的函数。ToXElement方法可能是一个很好的选择。
使用动态成员重载解析,我们可以在一个单独的类中编写ToXElement功能,而不需要基于类型的丑陋开关:
class ToXElementPersonVisitor
{
public XElement DynamicVisit (Person p) => Visit ((dynamic)p);
XElement Visit (Person p)
{
return new XElement ("Person",
new XAttribute ("Type", p.GetType().Name),
new XElement ("FirstName", p.FirstName),
new XElement ("LastName", p.LastName),
p.Friends.Select (f => DynamicVisit (f))
);
}
XElement Visit (Customer c) // Specialized logic for customers
{
XElement xe = Visit ((Person)c); // Call "base" method
xe.Add (new XElement ("CreditLimit", c.CreditLimit));
return xe;
}
XElement Visit (Employee e) // Specialized logic for employees
{
XElement xe = Visit ((Person)e); // Call "base" method
xe.Add (new XElement ("Salary", e.Salary));
return xe;
}
}
DynamicVisit方法执行动态分派——根据运行时确定的最具体版本调用Visit。注意粗体行中的内容,在其中我们对Friends集合中的每个人调用DynamicVisit方法。这确保如果朋友是Customer或Employee,则调用正确的重载。
我们可以演示这个类,如下所示:
var cust = new Customer
{
FirstName = "Joe", LastName = "Bloggs", CreditLimit = 123
};
cust.Friends.Add (
new Employee { FirstName = "Sue", LastName = "Brown", Salary = 50000 }
);
Console.WriteLine (new ToXElementPersonVisitor().DynamicVisit (cust));
结果如下:
<Person Type="Customer">
<FirstName>Joe</FirstName>
<LastName>Bloggs</LastName>
<Person Type="Employee">
<FirstName>Sue</FirstName>
<LastName>Brown</LastName>
<Salary>50000</Salary>
</Person>
<CreditLimit>123</CreditLimit>
</Person>
变化
如果计划多个访问者类,一个有用的变化是为访问者定义一个抽象基类:
abstract class PersonVisitor<T>
{
public T DynamicVisit (Person p) { return Visit ((dynamic)p); }
protected abstract T Visit (Person p);
protected virtual T Visit (Customer c) { return Visit ((Person) c); }
protected virtual T Visit (Employee e) { return Visit ((Person) e); }
}
然后子类不需要定义自己的DynamicVisit方法:它们只需重写Visit的版本,以便专门化它们想要的行为。这样做的优点是集中包含Person层次结构的方法,并允许实现者更自然地调用基本方法:
class ToXElementPersonVisitor : PersonVisitor<XElement>
{
protected override XElement Visit (Person p)
{
return new XElement ("Person",
new XAttribute ("Type", p.GetType().Name),
new XElement ("FirstName", p.FirstName),
new XElement ("LastName", p.LastName),
p.Friends.Select (f => DynamicVisit (f))
);
}
protected override XElement Visit (Customer c)
{
XElement xe = base.Visit (c);
xe.Add (new XElement ("CreditLimit", c.CreditLimit));
return xe;
}
protected override XElement Visit (Employee e)
{
XElement xe = base.Visit (e);
xe.Add (new XElement ("Salary", e.Salary));
return xe;
}
}
随后你甚至可以直接继承ToXElementPersonVisitor本身。
匿名调用泛型类型的成员
C#静态类型的严格性是一把双刃剑。一方面,它在编译时强制执行一定程度的正确性。另一方面,它偶尔会使某些类型的代码难以表达,这时你必须依赖反射。在这些情况下,动态绑定是反射的一个更清晰和更快速的替代方案。
当你需要处理类型为G<T>的对象时,其中T是未知的时候,我们可以通过定义以下类来说明这一点:
public class Foo<T> { public T Value; }
假设我们接着按以下方式编写一个方法:
static void Write (object obj)
{
if (obj is Foo<>) // Illegal
Console.WriteLine ((Foo<>) obj).Value); // Illegal
}
这个方法不会编译通过:你不能调用未绑定泛型类型的成员。
动态绑定提供了两种方法来解决这个问题。第一种是动态访问Value成员,如下所示:
static void Write (dynamic obj)
{
try { Console.WriteLine (obj.Value); }
catch (Microsoft.CSharp.RuntimeBinder.RuntimeBinderException) {...}
}
这样做的(潜在)优势是能够与定义 Value 字段或属性的任何对象一起工作。然而,存在一些问题。首先,在这种方式中捕获异常有些凌乱和效率低下(并且没有办法事先询问 DLR,“这个操作会成功吗?”)。其次,如果 Foo 是一个接口(比如 IFoo<T>)并且满足以下任一条件,则会失败:
-
Value已经显式实现 -
实现了
IFoo<T>的类型是不可访问的(稍后详述)
更好的解决方案是编写一个名为 GetFooValue 的重载辅助方法,并使用 动态成员重载解析 调用它:
static void Write (dynamic obj)
{
object result = GetFooValue (obj);
if (result != null) Console.WriteLine (result);
}
static T GetFooValue<T> (Foo<T> foo) => foo.Value;
static object GetFooValue (object foo) => null;
注意,我们重载了 GetFooValue 来接受一个 object 参数,它作为任何类型的后备。在运行时,C# 的动态绑定器将在调用 GetFooValue 时选择最佳重载。如果涉及的对象不基于 Foo<T>,它将选择对象参数的重载,而不是抛出异常。
注意
另一种选择是仅编写第一个 GetFooValue 重载,然后捕获 RuntimeBinderException。优点是可以区分 foo.Value 为 null 的情况。缺点是会产生抛出和捕获异常的性能开销。
在 第十八章 中,我们使用反射解决了使用接口的相同问题,需要更多的工作(参见 “匿名调用泛型接口的成员”)。我们使用的示例是设计一个更强大的 ToString() 版本,能够理解诸如 IEnumerable 和 IGrouping<,> 等对象。以下是使用动态绑定更优雅地解决相同示例:
static string GetGroupKey<TKey,TElement> (IGrouping<TKey,TElement> group)
=> "Group with key=" + group.Key + ": ";
static string GetGroupKey (object source) => null;
public static string ToStringEx (object value)
{
if (value == null) return "<null>";
if (value is string s) return s;
if (value.GetType().IsPrimitive) return value.ToString();
StringBuilder sb = new StringBuilder();
string groupKey = GetGroupKey ((dynamic)value); // Dynamic dispatch
if (groupKey != null) sb.Append (groupKey);
if (value is IEnumerable)
foreach (object element in ((IEnumerable)value))
sb.Append (ToStringEx (element) + " ");
if (sb.Length == 0) sb.Append (value.ToString());
return "\r\n" + sb.ToString();
}
下面是它的作用:
Console.WriteLine (ToStringEx ("xyyzzz".GroupBy (c => c) ));
*Group with key=x: x*
*Group with key=y: y y*
*Group with key=z: z z z*
请注意,我们使用动态 成员重载解析 来解决这个问题。如果我们反而做了以下操作:
dynamic d = value;
try { groupKey = d.Value); }
catch (Microsoft.CSharp.RuntimeBinder.RuntimeBinderException) {...}
它会因为 LINQ 的 GroupBy 操作符返回实现 IGrouping<,> 的类型,而该类型本身是内部的,因此不可访问:
internal class Grouping : IGrouping<TKey,TElement>, ...
{
public TKey Key;
...
}
即使 Key 属性被声明为 public,它所在的类将其限制在 internal,因此只能通过 IGrouping<,> 接口访问。并且正如在 第四章 中所解释的,当动态调用 Value 成员时,没有办法指示 DLR 绑定到该接口。
实现动态对象
一个对象可以通过实现 IDynamicMetaObjectProvider 提供其绑定语义,或者更简单地通过子类化 DynamicObject,后者提供了此接口的默认实现。这在 第四章 中通过以下示例简要演示:
dynamic d = new Duck();
d.Quack(); // Quack method was called
d.Waddle(); // Waddle method was called
public class Duck : DynamicObject
{
public override bool TryInvokeMember (
InvokeMemberBinder binder, object[] args, out object result)
{
Console.WriteLine (binder.Name + " method was called");
result = null;
return true;
}
}
DynamicObject
在前面的示例中,我们重写了TryInvokeMember,允许使用者在动态对象上调用方法——例如Quack或Waddle。DynamicObject公开了其他虚拟方法,使消费者能够使用其他编程构造。以下是在 C#中具有表示的对应构造:
| Method | 编程构造 |
|---|---|
TryInvokeMember | 方法 |
TryGetMember, TrySetMember | 属性或字段 |
TryGetIndex, TrySetIndex | 索引器 |
TryUnaryOperation | 例如 ! 的一元操作符 |
TryBinaryOperation | 例如 == 的二元操作符 |
TryConvert | 转换(类型转换)为另一种类型 |
TryInvoke | 在对象本身上的调用——例如d("foo") |
如果这些方法成功返回true,则它们应返回true。如果它们返回false,DLR 将回退到语言绑定程序,查找DynamicObject(子类)本身的匹配成员。如果失败,将抛出RuntimeBinderException。
我们可以通过一个类来说明TryGetMember和TrySetMember,该类允许我们动态访问XElement中的属性(System.Xml.Linq):
static class XExtensions
{
public static dynamic DynamicAttributes (this XElement e)
=> new XWrapper (e);
class XWrapper : DynamicObject
{
XElement _element;
public XWrapper (XElement e) { _element = e; }
public override bool TryGetMember (GetMemberBinder binder,
out object result)
{
result = _element.Attribute (binder.Name).Value;
return true;
}
public override bool TrySetMember (SetMemberBinder binder,
object value)
{
_element.SetAttributeValue (binder.Name, value);
return true;
}
}
}
这里是如何使用它的:
XElement x = XElement.Parse (@"<Label Text=""Hello"" Id=""5""/>");
dynamic da = x.DynamicAttributes();
Console.WriteLine (da.Id); // 5
da.Text = "Foo";
Console.WriteLine (x.ToString()); // <Label Text="Foo" Id="5" />
以下对System.Data.IDataRecord做了类似的事情,使得使用数据读取器更加简单:
public class DynamicReader : DynamicObject
{
readonly IDataRecord _dataRecord;
public DynamicReader (IDataRecord dr) { _dataRecord = dr; }
public override bool TryGetMember (GetMemberBinder binder,
out object result)
{
result = _dataRecord [binder.Name];
return true;
}
}
...
using (IDataReader reader = *someDbCommand*.ExecuteReader())
{
dynamic dr = new DynamicReader (reader);
while (reader.Read())
{
int id = dr.ID;
string firstName = dr.FirstName;
DateTime dob = dr.DateOfBirth;
...
}
}
以下展示了TryBinaryOperation和TryInvoke的示例:
dynamic d = new Duck();
Console.WriteLine (d + d); // foo
Console.WriteLine (d (78, 'x')); // 123
public class Duck : DynamicObject
{
public override bool TryBinaryOperation (BinaryOperationBinder binder,
object arg, out object result)
{
Console.WriteLine (binder.Operation); // Add
result = "foo";
return true;
}
public override bool TryInvoke (InvokeBinder binder,
object[] args, out object result)
{
Console.WriteLine (args[0]); // 78
result = 123;
return true;
}
}
DynamicObject还公开了一些为动态语言提供的虚拟方法。特别是,重写GetDynamicMemberNames允许您返回您的动态对象提供的所有成员名称列表。
注意
另一个实现GetDynamicMemberNames的原因是 Visual Studio 的调试器利用此方法显示动态对象的视图。
ExpandoObject
DynamicObject的另一个简单应用是编写一个动态类,该类通过字符串键存储和检索对象。然而,通过ExpandoObject类已提供了这种功能:
dynamic x = new ExpandoObject();
x.FavoriteColor = ConsoleColor.Green;
x.FavoriteNumber = 7;
Console.WriteLine (x.FavoriteColor); // Green
Console.WriteLine (x.FavoriteNumber); // 7
ExpandoObject实现了IDictionary<string,object>,因此我们可以继续我们的示例并执行以下操作:
var dict = (IDictionary<string,object>) x;
Console.WriteLine (dict ["FavoriteColor"]); // Green
Console.WriteLine (dict ["FavoriteNumber"]); // 7
Console.WriteLine (dict.Count); // 2
与动态语言的互操作性
尽管 C#通过dynamic关键字支持动态绑定,但它并不允许您在运行时执行描述在字符串中的表达式这么远:
string expr = "2 * 3";
// We can’t "execute" expr
这是因为将字符串转换为表达式树的代码需要词法和语义分析器。这些功能内置于 C#编译器中,不作为运行时服务提供。在运行时,C#仅提供一个binder,它指示 DLR 如何解释已构建的表达式树。
真正的动态语言,如 IronPython 和 IronRuby,确实允许您执行任意字符串,这在诸如脚本编写、编写动态配置系统和实现动态规则引擎等任务中非常有用。因此,尽管您可以在 C#中编写大部分应用程序,但在这些任务中调用动态语言可能很有用。此外,您可能希望使用用动态语言编写的 API,在.NET 库中没有等效功能的情况下使用。
注意
Roslyn 脚本化 NuGet 包Microsoft.CodeAnalysis.CSharp.Scripting提供了一个 API,让您可以执行 C#字符串,尽管它是通过将您的代码编译成程序来实现的。编译开销使其比 Python 互操作更慢,除非您打算重复执行相同的表达式。
在以下示例中,我们使用 IronPython 来评估在 C#中动态创建的表达式。您可以使用以下脚本编写计算器。
注意
要运行此代码,请将 NuGet 包DynamicLanguageRuntime(不要与System.Dynamic.Runtime包混淆)和IronPython添加到您的应用程序中。
using System;
using IronPython.Hosting;
using Microsoft.Scripting;
using Microsoft.Scripting.Hosting;
int result = (int) Calculate ("2 * 3");
Console.WriteLine (result); // 6
object Calculate (string expression)
{
ScriptEngine engine = Python.CreateEngine();
return engine.Execute (expression);
}
因为我们将一个字符串传递给 Python,所以表达式将按照 Python 的规则进行评估,而不是 C#的规则。这也意味着我们可以使用 Python 的语言特性,比如列表:
var list = (IEnumerable) Calculate ("[1, 2, 3] + [4, 5]");
foreach (int n in list) Console.Write (n); // 12345
在 C#和脚本之间传递状态
要将变量从 C#传递到 Python,需要进行几个额外的步骤。以下示例说明了这些步骤,并可以作为规则引擎的基础:
// The following string could come from a file or database:
string auditRule = "taxPaidLastYear / taxPaidThisYear > 2";
ScriptEngine engine = Python.CreateEngine ();
ScriptScope scope = engine.CreateScope ();
scope.SetVariable ("taxPaidLastYear", 20000m);
scope.SetVariable ("taxPaidThisYear", 8000m);
ScriptSource source = engine.CreateScriptSourceFromString (
auditRule, SourceCodeKind.Expression);
bool auditRequired = (bool) source.Execute (scope);
Console.WriteLine (auditRequired); // True
通过调用GetVariable也可以获取变量:
string code = "result = input * 3";
ScriptEngine engine = Python.CreateEngine();
ScriptScope scope = engine.CreateScope();
scope.SetVariable ("input", 2);
ScriptSource source = engine.CreateScriptSourceFromString (code,
SourceCodeKind.SingleStatement);
source.Execute (scope);
Console.WriteLine (scope.GetVariable ("result")); // 6
请注意,在第二个示例中,我们指定了SourceCodeKind.SingleStatement(而不是Expression),以通知引擎我们要执行一个语句。
类型在.NET 和 Python 世界之间自动驱动。您甚至可以从脚本侧访问.NET 对象的成员:
string code = @"sb.Append (""World"")";
ScriptEngine engine = Python.CreateEngine ();
ScriptScope scope = engine.CreateScope ();
var sb = new StringBuilder ("Hello");
scope.SetVariable ("sb", sb);
ScriptSource source = engine.CreateScriptSourceFromString (
code, SourceCodeKind.SingleStatement);
source.Execute (scope);
Console.WriteLine (sb.ToString()); // HelloWorld
第二十章:密码学
在本章中,我们讨论了.NET 中的主要密码学 API:
-
Windows 数据保护 API(DPAPI)
-
哈希
-
对称加密
-
公钥加密和签名
本章涵盖的类型在以下命名空间中定义:
System.Security;
System.Security.Cryptography;
概述
表 20-1 总结了.NET 中的密码学选项。在接下来的几节中,我们将详细探讨每一种选项。
表 20-1. .NET 中的加密和哈希选项
| 选项 | 管理的密钥 | 速度 | 强度 | 注释 |
|---|---|---|---|---|
File.Encrypt | 0 | 快速 | 依赖用户的密码 | 通过文件系统支持透明地保护文件。从当前登录用户的凭据隐式派生密钥。仅限 Windows 操作系统。 |
| Windows 数据保护 | 0 | 快速 | 依赖用户的密码 | 使用隐式派生密钥加密和解密字节数组。 |
| 哈希 | 0 | 快速 | 高 | 单向(不可逆)转换。用于存储密码、比较文件和检查数据完整性。 |
| 对称加密 | 1 | 快速 | 高 | 用于通用加密/解密。同一密钥加密和解密。可用于安全传输消息。 |
| 公钥加密 | 2 | 慢 | 高 | 加密和解密使用不同的密钥。用于在消息传输中交换对称密钥和数字签名文件。 |
.NET 还提供了更专门的支持,用于创建和验证基于 XML 的签名,位于System.Security.Cryptography.Xml命名空间,以及用于处理数字证书的类型,位于System.Security.Cryptography.X509Certificates命名空间。
Windows 数据保护
注意
Windows 数据保护仅在 Windows 操作系统上可用,在其他操作系统上会抛出PlatformNotSupportedException异常。
在“文件和目录操作”部分中,我们描述了如何使用File.Encrypt请求操作系统透明地加密文件:
File.WriteAllText ("myfile.txt", "");
File.Encrypt ("myfile.txt");
File.AppendAllText ("myfile.txt", "sensitive data");
在这种情况下的加密使用从当前登录用户密码派生的密钥。您可以使用同样的隐式派生密钥,通过 Windows 数据保护 API(DPAPI)加密字节数组。DPAPI 通过ProtectedData类公开——这是一个具有两个静态方法的简单类型:
public static byte[] Protect
(byte[] userData, byte[] optionalEntropy, DataProtectionScope scope);
public static byte[] Unprotect
(byte[] encryptedData, byte[] optionalEntropy, DataProtectionScope scope);
无论您在optionalEntropy中包含什么,都会添加到密钥中,从而增加其安全性。DataProtectionScope枚举参数允许两个选项:CurrentUser或LocalMachine。使用CurrentUser,密钥从当前登录用户的凭据派生;使用LocalMachine,则使用全局的机器密钥,适用于所有用户。这意味着使用CurrentUser范围加密的数据不能被其他用户解密。LocalMachine密钥提供较少的保护,但适用于需要在多个账户下操作的 Windows 服务或程序。
这里是一个简单的加密和解密演示:
byte[] original = {1, 2, 3, 4, 5};
DataProtectionScope scope = DataProtectionScope.CurrentUser;
byte[] encrypted = ProtectedData.Protect (original, null, scope);
byte[] decrypted = ProtectedData.Unprotect (encrypted, null, scope);
// decrypted is now {1, 2, 3, 4, 5}
Windows 数据保护根据用户密码的强度提供了对拥有完全访问权限的攻击者的中等安全性保护。在LocalMachine范围内,它仅对那些受限的物理和电子访问者有效。
哈希计算
哈希算法将一个潜在的大量字节压缩为一个固定长度的哈希码。哈希算法设计得如此,源数据的任何单比特更改都会导致显著不同的哈希码。这使得它适用于比较文件或检测文件或数据流的意外(或恶意)损坏。
哈希还充当单向加密,因为很难或不可能将哈希码转换回原始数据。这使其非常适合将密码存储在数据库中,因为如果您的数据库被攻击者入侵,您不希望攻击者能够访问明文密码。要进行身份验证,只需对用户输入的内容进行哈希并将其与数据库中存储的哈希进行比较。
要进行哈希计算,可以在HashAlgorithm的子类(如SHA1或SHA256)上调用ComputeHash:
byte[] hash;
using (Stream fs = File.OpenRead ("checkme.doc"))
hash = SHA1.Create().ComputeHash (fs); // SHA1 hash is 20 bytes long
ComputeHash方法还接受字节数组作为参数,这对于哈希密码非常方便(我们在“哈希密码”中描述了更安全的技术):
byte[] data = System.Text.Encoding.UTF8.GetBytes ("stRhong%pword");
byte[] hash = SHA256.Create().ComputeHash (data);
注意
在Encoding对象上调用GetBytes方法将字符串转换为字节数组;调用GetString方法将其转换回来。然而,Encoding对象不能将加密或哈希的字节数组转换为字符串,因为乱序的数据通常违反文本编码规则。取而代之的是使用Convert.ToBase64String和Convert.FromBase64String:它们可以在任何字节数组和合法(且适于 XML 或 JSON)的字符串之间转换。
.NET 中的哈希算法
SHA1和SHA256是.NET 提供的HashAlgorithm子类型之一。以下是主要算法,按安全性从低到高排序:
| 类别 | 算法 | 哈希长度(字节) | 强度 |
|---|---|---|---|
MD5 | MD5 | 16 | 非常差 |
SHA1 | SHA-1 | 20 | 差 |
SHA256 | SHA-2 | 32 | 良好 |
SHA384 | SHA-2 | 48 | 良好 |
SHA512 | SHA-2 | 64 | 良好 |
所有五种算法在当前实现中执行速度大致相似,除了 SHA256,它比其他算法快 2-3 倍(这可能会随硬件和操作系统的不同而有所变化)。以一个大概的数字为例,您可以期望在 2024 年的桌面或服务器上,所有算法至少达到每秒 500 MB。较长的哈希值降低了碰撞(两个不同的文件产生相同的哈希值)的可能性。
警告
使用至少SHA256来哈希密码或其他安全敏感数据。MD5和SHA1被认为在此目的上不安全,仅适用于防止意外损坏,而非故意篡改。
注意
.NET 8 及更高版本还通过 SHA3_256、SHA3_384 和 SHA3_512 类支持最新的 SHA-3 哈希算法。SHA-3 算法被认为比前面列出的算法更安全(但更慢),但需要 Windows Build 25324+ 或具有 OpenSSL 1.1.1+ 的 Linux。可以通过这些类的静态 IsSupported 属性来测试操作系统是否支持。
密码哈希
如果你要求强密码策略以减轻字典攻击(即攻击者通过对字典中的每个单词进行哈希来构建密码查找表的策略),那么更长的 SHA 算法适合用作密码哈希的基础。
在哈希密码时的一个标准技术是将“盐”——一系列长字节——合并到每个密码之前,然后再进行哈希。这种做法通过两种方式使黑客感到沮丧:
-
他们还必须知道盐字节。
-
他们无法使用彩虹表(公开可用的预计算密码及其哈希码数据库),尽管使用足够的计算能力可能仍然可以进行字典攻击。
要进一步增强安全性,可以通过“拉伸”密码哈希来实现——重复重新哈希以获得更加计算密集的字节序列。如果重新哈希 100 次,一个原本需要一个月才能完成的字典攻击将需要八年时间。KeyDerivation、Rfc2898DeriveBytes 和 PasswordDeriveBytes 类可以执行这种类型的拉伸,并且允许方便的盐化。其中,KeyDerivation.Pbkdf2 提供了最佳的哈希算法:
byte[] encrypted = KeyDerivation.Pbkdf2 (
password: "stRhong%pword",
salt: Encoding.UTF8.GetBytes ("j78Y#p)/saREN!y3@"),
prf: KeyDerivationPrf.HMACSHA512,
iterationCount: 100,
numBytesRequested: 64);
注意
KeyDerivation.Pbkdf2 需要 NuGet 包 Microsoft.AspNetCore.Cryptography.KeyDerivation。尽管它位于 ASP.NET Core 命名空间中,但任何 .NET 应用程序都可以使用它。
对称加密
对称加密使用相同的密钥进行加密和解密。.NET BCL 提供了四种对称算法,其中 Rijndael(发音为“Rhine Dahl”或“Rain Doll”)是最优秀的;其他算法主要用于与旧应用程序的兼容性。Rijndael 既快速又安全,并且有两种实现方式:
-
Rijndael类 -
Aes类
这两者几乎是相同的,唯一的区别在于 Aes 不允许通过更改块大小来削弱密码。CLR 安全团队推荐使用 Aes。
Rijndael 和 Aes 允许使用长度为 16、24 或 32 字节的对称密钥:所有这些密钥长度目前都被认为是安全的。以下是如何使用 16 字节密钥将一系列字节加密并写入文件的方法:
byte[] key = {145,12,32,245,98,132,98,214,6,77,131,44,221,3,9,50};
byte[] iv = {15,122,132,5,93,198,44,31,9,39,241,49,250,188,80,7};
byte[] data = { 1, 2, 3, 4, 5 }; // This is what we're encrypting.
using (SymmetricAlgorithm algorithm = Aes.Create())
using (ICryptoTransform encryptor = algorithm.CreateEncryptor (key, iv))
using (Stream f = File.Create ("encrypted.bin"))
using (Stream c = new CryptoStream (f, encryptor, CryptoStreamMode.Write))
c.Write (data, 0, data.Length);
以下代码解密文件:
byte[] key = {145,12,32,245,98,132,98,214,6,77,131,44,221,3,9,50};
byte[] iv = {15,122,132,5,93,198,44,31,9,39,241,49,250,188,80,7};
byte[] decrypted = new byte[5];
using (SymmetricAlgorithm algorithm = Aes.Create())
using (ICryptoTransform decryptor = algorithm.CreateDecryptor (key, iv))
using (Stream f = File.OpenRead ("encrypted.bin"))
using (Stream c = new CryptoStream (f, decryptor, CryptoStreamMode.Read))
for (int b; (b = c.ReadByte()) > -1;)
Console.Write (b + " "); // 1 2 3 4 5
在这个例子中,我们使用了 16 个随机选择的字节来生成密钥。如果在解密时使用了错误的密钥,CryptoStream 将抛出 CryptographicException。捕获此异常是测试密钥是否正确的唯一方法。
除了密钥,我们还制定了一个 IV,或初始化向量。这个 16 字节的序列是密码的一部分——类似于密钥——但不被视为机密。如果您要传输加密的消息,可以在明文中发送 IV(可能在消息头中),然后每条消息都更改它。这将使每条加密消息与任何先前的消息在外观上都不相似,即使它们的未加密版本相似或相同。
注意
如果您不需要——或不想要——IV 的保护,可以通过同时使用相同的 16 字节值作为密钥和 IV 来避免它。然而,使用相同 IV 发送多条消息会削弱密码,并且甚至可能使其被破解。
密码工作分布在不同的类中。Aes 是数学家;它应用密码算法,以及它的 encryptor 和 decryptor 转换。CryptoStream 是管道工;它负责流的管道。您可以用不同的对称算法替换 Aes,但仍然可以使用 CryptoStream。
CryptoStream 是双向的,这意味着您可以根据选择的 CryptoStreamMode.Read 或 CryptoStreamMode.Write 从流中读取或写入数据。加密器和解密器都具备读取和写入的能力,产生四种组合——这个选择可能会让您盯着空白屏幕发呆一段时间!将读取视为“拉取”,写入视为“推送”可能会有所帮助。如果有疑问,可以从加密时选择 Write,解密时选择 Read 开始;这通常是最自然的选择。
要生成随机的密钥或 IV,请使用 System.Cryptography 中的 RandomNumberGenerator。它产生的数字是真正不可预测的,或者密码学强度(System.Random 类无法提供相同的保证)。以下是一个例子:
byte[] key = new byte [16];
byte[] iv = new byte [16];
RandomNumberGenerator rand = RandomNumberGenerator.Create();
rand.GetBytes (key);
rand.GetBytes (iv);
或者,从 .NET 6 开始:
byte[] key = RandomNumberGenerator.GetBytes (16);
byte[] iv = RandomNumberGenerator.GetBytes (16);
如果您没有指定密钥和 IV,则会自动生成密码学上强大的随机值。您可以通过 Aes 对象的 Key 和 IV 属性查询这些值。
在内存中加密
从 .NET 6 开始,您可以利用 EncryptCbc 和 DecryptCbc 方法来简化字节数组的加密和解密过程:
public static byte[] Encrypt (byte[] data, byte[] key, byte[] iv)
{
using Aes algorithm = Aes.Create();
algorithm.Key = key;
return algorithm.EncryptCbc (data, iv);
}
public static byte[] Decrypt (byte[] data, byte[] key, byte[] iv)
{
using Aes algorithm = Aes.Create();
algorithm.Key = key;
return algorithm.DecryptCbc (data, iv);
}
这是在所有 .NET 版本中都有效的等效方法:
public static byte[] Encrypt (byte[] data, byte[] key, byte[] iv)
{
using (Aes algorithm = Aes.Create())
using (ICryptoTransform encryptor = algorithm.CreateEncryptor (key, iv))
return Crypt (data, encryptor);
}
public static byte[] Decrypt (byte[] data, byte[] key, byte[] iv)
{
using (Aes algorithm = Aes.Create())
using (ICryptoTransform decryptor = algorithm.CreateDecryptor (key, iv))
return Crypt (data, decryptor);
}
static byte[] Crypt (byte[] data, ICryptoTransform cryptor)
{
MemoryStream m = new MemoryStream();
using (Stream c = new CryptoStream (m, cryptor, CryptoStreamMode.Write))
c.Write (data, 0, data.Length);
return m.ToArray();
}
在这里,CryptoStreamMode.Write 在加密和解密时都能很好地工作,因为在这两种情况下,我们都在“推送”到一个新的内存流中。
这里有接受和返回字符串的重载:
public static string Encrypt (string data, byte[] key, byte[] iv)
{
return Convert.ToBase64String (
Encrypt (Encoding.UTF8.GetBytes (data), key, iv));
}
public static string Decrypt (string data, byte[] key, byte[] iv)
{
return Encoding.UTF8.GetString (
Decrypt (Convert.FromBase64String (data), key, iv));
}
以下演示了它们的使用:
byte[] key = new byte[16];
byte[] iv = new byte[16];
var cryptoRng = RandomNumberGenerator.Create();
cryptoRng.GetBytes (key);
cryptoRng.GetBytes (iv);
string encrypted = Encrypt ("Yeah!", key, iv);
Console.WriteLine (encrypted); // R1/5gYvcxyR2vzPjnT7yaQ==
string decrypted = Decrypt (encrypted, key, iv);
Console.WriteLine (decrypted); // Yeah!
链式加密流
CryptoStream 是一个装饰器,意味着您可以将它与其他流链接起来。在下面的示例中,我们将压缩加密文本写入文件,然后再读取回来:
byte[] key = new byte [16];
byte[] iv = new byte [16];
var cryptoRng = RandomNumberGenerator.Create();
cryptoRng.GetBytes (key);
cryptoRng.GetBytes (iv);
using (Aes algorithm = Aes.Create())
{
using (ICryptoTransform encryptor = algorithm.CreateEncryptor(key, iv))
using (Stream f = File.Create ("serious.bin"))
using (Stream c = new CryptoStream (f, encryptor, CryptoStreamMode.Write))
using (Stream d = new DeflateStream (c, CompressionMode.Compress))
using (StreamWriter w = new StreamWriter (d))
await w.WriteLineAsync ("Small and secure!");
using (ICryptoTransform decryptor = algorithm.CreateDecryptor(key, iv))
using (Stream f = File.OpenRead ("serious.bin"))
using (Stream c = new CryptoStream (f, decryptor, CryptoStreamMode.Read))
using (Stream d = new DeflateStream (c, CompressionMode.Decompress))
using (StreamReader r = new StreamReader (d))
Console.WriteLine (await r.ReadLineAsync()); // Small and secure!
}
(最后一步,通过调用 WriteLineAsync 和 ReadLineAsync 使我们的程序异步化,并等待结果。)
在这个例子中,所有单字母变量都是链的一部分。数学家——algorithm、encryptor 和 decryptor——在密码流的工作中起到了辅助作用,正如 图 20-1 所示。
以这种方式链接流无论最终流的大小如何,都需要很少的内存。
图 20-1. 加密和压缩流的链接
处置加密对象
处置CryptoStream确保其内部数据缓存刷新到底层流中。内部缓存对于加密算法是必需的,因为它们以块而不是逐字节方式处理数据。
CryptoStream的不寻常之处在于其Flush方法什么也不做。要刷新流(而不是释放它),必须调用FlushFinalBlock。与Flush相反,只能调用FlushFinalBlock一次,然后不能再写入更多数据。
我们还处理了数学家——Aes算法和ICryptoTransform对象(encryptor和decryptor)。当 Rijndael 变换被处理时,它们会从内存中擦除对称密钥和相关数据,防止其他在计算机上运行的软件(我们说的是恶意软件)后续发现。您不能依赖垃圾收集器来执行此任务,因为它仅仅将内存部分标记为可用;它不会在每个字节上写零。
在using语句外释放Aes对象最简单的方法是调用Clear方法。它的Dispose方法通过显式实现隐藏(用于表示其不寻常的处理语义,即清除内存而不是释放非托管资源)。
注意
您可以通过以下方法进一步减少应用程序通过释放的内存泄露秘密的风险:
-
避免使用字符串存储安全信息(由于不可变性,一旦创建,字符串的值就无法清除)
-
一旦不再需要,立即覆盖缓冲区(例如,在字节数组上调用
Array.Clear)
密钥管理
密钥管理是安全性的关键因素:如果您的密钥暴露了,那么您的数据也暴露了。您需要考虑谁应该访问密钥,以及如何在硬件故障时进行备份,同时以防止未经授权的访问方式存储它们。
不建议硬编码加密密钥,因为存在流行的工具可以轻松反编译程序集,无需专业知识。更好的选择(在 Windows 上)是为每个安装制造一个随机密钥,并安全地存储在 Windows 数据保护中。
对于部署到云中的应用程序,Microsoft Azure 和 Amazon Web Services(AWS)提供了具有额外功能的密钥管理系统,这些功能在企业环境中可能非常有用,例如审计跟踪。如果您正在加密消息流,公钥加密仍然提供了最佳选择。
公钥加密和签名
公钥加密是非对称的,意味着加密和解密使用不同的密钥。
与对称加密不同,对称加密可以使用任意长度的任意字节序列作为密钥,非对称加密需要专门制作的密钥对。密钥对包含一对公钥和私钥组件,它们如下配合工作:
-
公钥加密消息。
-
私钥解密消息。
负责“制作”密钥对的一方会保持私钥保密,同时自由分发公钥。这种加密方式的特殊特性在于无法从公钥计算出私钥。因此,如果私钥丢失,则无法恢复加密数据;相反,如果私钥泄漏,则加密系统变得无用。
公钥握手允许两台计算机在公共网络上进行安全通信,无需先前联系和现有共享秘密。为了看到其工作原理,假设计算机Origin想要将机密消息发送给计算机Target:
-
Target生成一个公钥/私钥对,然后将其公钥发送给Origin。
-
Origin使用Target的公钥加密机密消息,然后将其发送给Target。
-
Target使用其私钥解密机密消息。
窃听者将看到以下内容:
-
Target的公钥
-
使用Target的公钥加密的秘密消息
但是没有Target的私钥,无法解密消息。
注意
这不能防止中间人攻击:换句话说,Origin无法知道Target是否某些恶意方。要对收件人进行身份验证,发起者需要已知收件人的公钥或能够通过数字站点证书验证其密钥。
因为公钥加密速度较慢且消息大小有限,从Origin发送到Target的秘密消息通常包含用于随后对称加密的新密钥。这允许在会话的其余部分放弃公钥加密,转而采用能够处理更大消息的对称算法。如果每个会话生成一个新的公钥/私钥对,则此协议尤其安全,因为然后无需在任何计算机上存储密钥。
注意
公钥加密算法依赖于消息大小小于密钥的情况。这使它们适合仅加密少量数据,例如随后对称加密的密钥。如果尝试加密远大于密钥大小一半的消息,则提供程序将引发异常。
RSA 类
.NET 提供多种非对称算法,其中 RSA 最为流行。以下是如何使用 RSA 加密和解密的方法:
byte[] data = { 1, 2, 3, 4, 5 }; // This is what we're encrypting.
using (var rsa = new RSACryptoServiceProvider())
{
byte[] encrypted = rsa.Encrypt (data, true);
byte[] decrypted = rsa.Decrypt (encrypted, true);
}
因为我们没有指定公钥或私钥,加密提供程序会自动使用默认长度为 1,024 位生成密钥对;您可以通过构造函数请求更长的密钥,以 8 字节的增量。对于安全关键的应用程序,建议请求 2,048 位:
var rsa = new RSACryptoServiceProvider (2048);
生成密钥对的计算密集型操作可能需要约 10 毫秒。因此,RSA 实现推迟到实际需要密钥时才执行,例如在调用Encrypt时。这使您有机会加载现有的密钥或密钥对(如果存在)。
方法ImportCspBlob和ExportCspBlob以字节数组格式加载和保存密钥。FromXmlString和ToXmlString以字符串格式执行相同的工作,字符串包含 XML 片段。布尔标志允许您指示在保存时是否包括私钥。以下是如何生成密钥对并将其保存到磁盘上的示例:
using (var rsa = new RSACryptoServiceProvider())
{
File.WriteAllText ("PublicKeyOnly.xml", rsa.ToXmlString (false));
File.WriteAllText ("PublicPrivate.xml", rsa.ToXmlString (true));
}
因为我们没有提供现有密钥,ToXmlString强制生成了一个新的密钥对(在第一次调用时)。在下一个示例中,我们将读取这些密钥并使用它们来加密和解密消息。
byte[] data = Encoding.UTF8.GetBytes ("Message to encrypt");
string publicKeyOnly = File.ReadAllText ("PublicKeyOnly.xml");
string publicPrivate = File.ReadAllText ("PublicPrivate.xml");
byte[] encrypted, decrypted;
using (var rsaPublicOnly = new RSACryptoServiceProvider())
{
rsaPublicOnly.FromXmlString (publicKeyOnly);
encrypted = rsaPublicOnly.Encrypt (data, true);
// The next line would throw an exception because you need the private
// key in order to decrypt:
// decrypted = rsaPublicOnly.Decrypt (encrypted, true);
}
using (var rsaPublicPrivate = new RSACryptoServiceProvider())
{
// With the private key we can successfully decrypt:
rsaPublicPrivate.FromXmlString (publicPrivate);
decrypted = rsaPublicPrivate.Decrypt (encrypted, true);
}
数字签名
您还可以使用公钥算法来对消息或文档进行数字签名。签名类似于哈希,但其生成需要私钥,因此无法伪造。公钥用于验证签名。以下是一个示例:
byte[] data = Encoding.UTF8.GetBytes ("Message to sign");
byte[] publicKey;
byte[] signature;
object hasher = SHA1.Create(); // Our chosen hashing algorithm.
// Generate a new key pair, then sign the data with it:
using (var publicPrivate = new RSACryptoServiceProvider())
{
signature = publicPrivate.SignData (data, hasher);
publicKey = publicPrivate.ExportCspBlob (false); // get public key
}
// Create a fresh RSA using just the public key, then test the signature.
using (var publicOnly = new RSACryptoServiceProvider())
{
publicOnly.ImportCspBlob (publicKey);
Console.Write (publicOnly.VerifyData (data, hasher, signature)); // True
// Let's now tamper with the data and recheck the signature:
data[0] = 0;
Console.Write (publicOnly.VerifyData (data, hasher, signature)); // False
// The following throws an exception as we're lacking a private key:
signature = publicOnly.SignData (data, hasher);
}
签名的工作方式是首先对数据进行哈希处理,然后应用非对称算法到结果哈希上。由于哈希值是固定大小的小块,因此可以相对快速地对大型文档进行签名(与哈希相比,公钥加密需要更多的 CPU 资源)。如果愿意,您可以自行进行哈希计算,然后调用SignHash而不是SignData:
using (var rsa = new RSACryptoServiceProvider())
{
byte[] hash = SHA1.Create().ComputeHash (data);
signature = rsa.SignHash (hash, CryptoConfig.MapNameToOID ("SHA1"));
...
}
SignHash仍然需要知道您使用的哈希算法;CryptoConfig.MapNameToOID从友好名称(如“SHA1”)提供此信息,以正确的格式提供。
RSACryptoServiceProvider生成的签名大小与密钥大小相匹配。目前,没有主流算法生成比 128 字节更小的安全签名(适用于产品激活码等)。
注意
为了签名有效,接收者必须知道并信任发送者的公钥。这可以通过先前的通信、预配置或网站证书来实现。网站证书是发起者公钥和名称的电子记录,由独立可信的机构签名。命名空间System.Security.Cryptography.X509Certificates定义了用于处理证书的类型。
第二十一章:高级线程
我们从基础线程(作为任务和异步的前导)开始第十四章。具体来说,我们展示了如何启动和配置线程,并涵盖了线程池、阻塞、自旋和同步上下文等关键概念。我们还介绍了锁定和线程安全,并演示了最简单的信号构件ManualResetEvent。
本章继续第十四章关于线程的话题。在前三节中,我们更详细地阐述了同步、锁定和线程安全。然后,我们涵盖了:
-
非独占锁定(
Semaphore和读写锁) -
所有信号构件(
AutoResetEvent、ManualResetEvent、CountdownEvent和Barrier) -
惰性初始化(
Lazy<T>和LazyInitializer) -
线程本地存储(
ThreadStaticAttribute、ThreadLocal<T>和GetData/SetData) -
定时器
线程是如此广泛的话题,我们在网上提供了额外的材料以完整展示整个情景。请访问http://albahari.com/threading进行更深入的讨论,包括以下更深奥的主题:
-
专用信号场景中使用
Monitor.Wait和Monitor.Pulse -
用于微优化的非阻塞同步技术(
Interlocked、内存屏障、volatile) -
高并发场景下的
SpinLock和SpinWait
同步概述
同步是协调并发操作以实现可预测结果的行为。在多个线程访问同一数据时,同步尤为重要;在这个领域中轻易犯错。
可能是最简单且最有用的同步工具,毫无疑问是第十四章中描述的延续和任务组合器。通过将并发程序制定为异步操作,并用延续和组合器串联起来,您可以减少对锁定和信号的需求。然而,在某些时候,低级构件仍然会发挥作用。
同步构件可分为三类:
独占锁定
独占锁定允许仅有一个线程执行某些活动或一段代码。它们的主要目的是让线程在不互相干扰的情况下访问共享写入状态。独占锁定构件包括lock、Mutex和SpinLock。
非独占锁定
非独占锁定允许限制并发性。非独占锁定构件包括Semaphore(Slim)和ReaderWriterLock(Slim)。
信号
这些允许线程在接收来自其他线程的一个或多个通知之前阻塞。信号构件包括ManualResetEvent(Slim)、AutoResetEvent、CountdownEvent和Barrier。前三者被称为事件等待句柄。
也可以(而且很棘手地)通过使用非阻塞同步构造在共享状态上执行某些并发操作。这些构造包括Thread.MemoryBarrier、Thread.VolatileRead、Thread.VolatileWrite、volatile关键字和Interlocked类。我们在在线文档中介绍了这个主题,同时还介绍了Monitor的Wait/Pulse方法,您可以使用它们来编写自定义的信号逻辑。
独占锁定
有三种独占锁定构造:lock语句、Mutex和SpinLock。lock语句是最方便和广泛使用的,而其他两种则针对特定的场景:
-
Mutex允许您跨多个进程进行跨计算机范围的锁定。 -
SpinLock实现了一种微优化,可以减少高并发场景中的上下文切换(参见http://albahari.com/threading)。
lock语句
为了说明锁定的必要性,考虑以下类:
class ThreadUnsafe
{
static int _val1 = 1, _val2 = 1;
static void Go()
{
if (_val2 != 0) Console.WriteLine (_val1 / _val2);
_val2 = 0;
}
}
这个类不是线程安全的:如果两个线程同时调用Go方法,可能会因为一个线程在另一个线程在执行if语句和Console.WriteLine之间将_val2设置为零时而导致除零错误。这里是lock如何解决这个问题的方式:
class ThreadSafe
{
static readonly object _locker = new object();
static int _val1 = 1, _val2 = 1;
static void Go()
{
lock (_locker)
{
if (_val2 != 0) Console.WriteLine (_val1 / _val2);
_val2 = 0;
}
}
}
只有一个线程可以一次锁定同步对象(在本例中为_locker),任何竞争线程都会被阻塞,直到锁被释放。如果有多个线程竞争锁,它们会排队在“准备队列”上,并按先到先服务的顺序获得锁。[¹] 独占锁有时被认为是强制串行化访问被锁定的内容,因为一个线程的访问不能与另一个线程的访问重叠。在这种情况下,我们保护Go方法内部的逻辑以及字段_val1和_val2。
Monitor.Enter和Monitor.Exit
C#的lock语句实际上是调用Monitor.Enter和Monitor.Exit方法,并带有try/finally块的语法快捷方式。这里是前面示例中Go方法中实际发生的(简化版):
Monitor.Enter (_locker);
try
{
if (_val2 != 0) Console.WriteLine (_val1 / _val2);
_val2 = 0;
}
finally { Monitor.Exit (_locker); }
在同一对象上调用Monitor.Exit之前未调用Monitor.Enter会引发异常。
lockTaken的重载版本
我们刚刚演示的代码存在一个微妙的漏洞。考虑在调用Monitor.Enter和try块之间抛出异常的情况(可能是由于OutOfMemoryException或在.NET Framework 中,线程被中止)。在这种情况下,锁可能会被获取,也可能不会被获取。如果锁被获取,它将不会被释放,因为我们永远不会进入try/finally块。这将导致锁泄漏。为了避免这种危险,Monitor.Enter定义了以下重载:
public static void Enter (object obj, ref bool lockTaken);
lockTaken如果(且仅当)Enter方法抛出异常且未获取锁时,其值为 false。
这是更健壮的使用模式(这正是 C# 如何转换 lock 语句的方式)。
bool lockTaken = false;
try
{
Monitor.Enter (_locker, ref lockTaken);
// Do your stuff...
}
finally { if (lockTaken) Monitor.Exit (_locker); }
TryEnter
Monitor 还提供了一个 TryEnter 方法,允许指定超时时间,可以是毫秒或 TimeSpan。该方法如果获取到锁则返回 true,如果因超时而未获取到锁则返回 false。TryEnter 还可以不带参数调用,如果无法立即获取锁则立即超时。与 Enter 方法一样,TryEnter 被重载以接受 lockTaken 参数。
选择同步对象
可以使用任何每个参与线程都可见的对象作为同步对象,但有一个硬性规定:它必须是引用类型。同步对象通常是私有的(因为这有助于封装锁定逻辑),通常是实例或静态字段。同步对象可以兼作其保护的对象,就像下面的示例中的 _list 字段一样。
class ThreadSafe
{
List <string> _list = new List <string>();
void Test()
{
lock (_list)
{
_list.Add ("Item 1");
...
专门用于锁定目的的字段(例如前面示例中的 _locker)允许对锁的范围和粒度进行精确控制。您还可以使用包含对象 (this) 作为同步对象:
lock (this) { ... }
或者甚至它的类型:
lock (typeof (Widget)) { ... } // For protecting access to statics
以这种方式锁定的缺点是,您没有封装锁定逻辑,因此更难以防止死锁和过度阻塞。
您还可以锁定 lambda 表达式或匿名方法捕获的局部变量。
注意
锁定并不以任何方式限制对同步对象本身的访问。换句话说,x.ToString() 不会因为另一个线程调用了 lock(x) 而被阻塞;要发生阻塞,两个线程必须同时调用 lock(x)。
何时进行锁定
作为基本规则,您需要在访问任何可写共享字段时进行锁定。即使在最简单的情况下——对单个字段的赋值操作——您也必须考虑同步。在下面的类中,Increment 和 Assign 方法都不是线程安全的:
class ThreadUnsafe
{
static int _x;
static void Increment() { _x++; }
static void Assign() { _x = 123; }
}
这里是 Increment 和 Assign 的线程安全版本:
static readonly object _locker = new object();
static int _x;
static void Increment() { lock (_locker) _x++; }
static void Assign() { lock (_locker) _x = 123; }
如果没有锁定,可能会出现两个问题:
-
操作,如递增变量(或者在某些条件下甚至读取/写入变量),不是原子的。
-
编译器、CLR 和处理器有权重新排序指令和缓存 CPU 寄存器中的变量以提高性能——只要这些优化不会改变单线程程序(或使用锁的多线程程序)的行为。
锁定可以缓解第二个问题,因为它在锁定之前和之后创建了一个内存屏障。内存屏障是一个“栅栏”,通过它,重新排序和缓存的效果无法穿透。
注意
这不仅适用于锁定,还适用于所有同步构造。因此,如果您使用信号构造确保只有一个线程在任一时间读取/写入变量,您无需锁定。因此,以下代码在围绕x加锁的情况下是线程安全的:
var signal = new ManualResetEvent (false);
int x = 0;
new Thread (() => { x++; signal.Set(); }).Start();
signal.WaitOne();
Console.WriteLine (x); // 1 *(always)*
在“非阻塞同步”一章中,我们解释了这种需求的起因以及内存屏障和Interlocked类在这些情况下提供的锁定替代方案。
锁定与原子性
如果一组变量总是在相同的锁定内读取和写入,您可以说这些变量被读取和写入原子化。假设字段 x 和 y 总是在对象 locker 上的 lock 内读取和赋值:
lock (locker) { if (x != 0) y /= x; }
我们可以说 x 和 y 由于代码块无法在另一个线程的操作中被分割或抢占,因此它们是原子访问的,这将改变 x 或 y 并使其失效的结果。只要在相同的独占锁内始终访问 x 和 y,您永远不会遇到除以零的错误。
注意
如果在lock块内抛出异常(无论是否涉及多线程),锁提供的原子性将被破坏。例如,请考虑以下情况:
decimal _savingsBalance, _checkBalance;
void Transfer (decimal amount)
{
lock (_locker)
{
_savingsBalance += amount;
_checkBalance -= amount + GetBankFee();
}
}
如果GetBankFee()抛出异常,银行将会损失资金。在这种情况下,我们可以通过早期调用GetBankFee来避免问题。在更复杂的情况下,可以在catch或finally块中实现“回滚”逻辑。
指令的原子性是一个不同但类似的概念:如果指令在底层处理器上以不可分割的方式执行,则该指令是原子的。
嵌套锁定
线程可以以嵌套(可重入)方式重复锁定同一对象:
lock (locker)
lock (locker)
lock (locker)
{
// Do something...
}
或者:
Monitor.Enter (locker); Monitor.Enter (locker); Monitor.Enter (locker);
// Do something...
Monitor.Exit (locker); Monitor.Exit (locker); Monitor.Exit (locker);
在这些场景中,对象只有在最外层的lock语句退出时——或者执行了匹配数量的Monitor.Exit语句后——才解锁。
当一个方法在锁定内调用另一个方法时,嵌套锁定非常有用:
object locker = new object();
lock (locker)
{
AnotherMethod();
// We still have the lock - because locks are reentrant.
}
void AnotherMethod()
{
lock (locker) { Console.WriteLine ("Another method"); }
}
一个线程只能在第一个(最外层)锁上阻塞。
死锁
当两个线程分别等待另一个持有的资源时,就会发生死锁,因此两者都无法继续进行。最简单的方法是使用两个锁来说明这一点:
object locker1 = new object();
object locker2 = new object();
new Thread (() => {
lock (locker1)
{
Thread.Sleep (1000);
lock (locker2); // Deadlock
}
}).Start();
lock (locker2)
{
Thread.Sleep (1000);
lock (locker1); // Deadlock
}
您可以使用三个或更多线程创建更复杂的死锁链。
注意
在标准托管环境中,CLR 不像 SQL Server 那样自动检测和解决死锁问题,它会无限期地阻塞参与的线程,除非您已指定锁定超时。(然而,在 SQL CLR 集成主机中,死锁会自动检测,并在其中一个线程上抛出一个[可捕获的]异常。)
在多线程中,死锁是最困难的问题之一——特别是当存在许多相互关联的对象时。从根本上说,难题在于您无法确定调用者已经获取了哪些锁。
因此,你可能会在类 x 内锁定私有字段 a,而不知道你的调用者(或调用者的调用者)已经在类 y 中锁定了字段 b。与此同时,另一个线程在做相反的事情——造成死锁。具有讽刺意味的是,这个问题被(好的)面向对象设计模式加剧了,因为这些模式创建的调用链直到运行时才确定。
流行的建议“按一致的顺序锁定对象以防止死锁”,虽然在我们的初始示例中很有帮助,但很难应用到刚才描述的场景中。一个更好的策略是小心在调用可能会引用回你自己对象的对象方法时进行锁定。此外,考虑一下是否真的需要在调用其他类方法时进行锁定(通常是需要的——正如你将在“锁定和线程安全”中看到的——但有时也有其他选择)。更多依赖于高级别同步选项,如任务延续/组合器、数据并行和不可变类型(本章后面会介绍)可以减少对锁定的需求。
注意
这里是另一种理解问题的方式:当你在持有锁的同时调用其他代码时,该锁的封装会微妙地泄漏。这不是 CLR 的错误;这是锁定在一般情况下的一个根本限制。锁定问题正在被各种研究项目解决,包括软件事务内存。
另一个死锁场景是在拥有锁时调用 Dispatcher.Invoke(在 WPF 应用程序中)或 Control.Invoke(在 Windows Forms 应用程序中)。如果用户界面碰巧正在运行另一个等待同一锁的方法,死锁将会发生。你通常可以通过简单地调用 BeginInvoke 而不是 Invoke(或者依赖于存在同步上下文时自动执行此操作的异步函数)来修复这个问题。或者,在调用 Invoke 之前释放你的锁,尽管如果你的调用者获取了锁,则这种方法不起作用。
性能
锁定速度很快:在 2020 年代的计算机上,如果锁没有争用,你可以期望在不到 20 纳秒内获取和释放一个锁。如果有争用,随之而来的上下文切换将使开销接近微秒区域,尽管在线程实际重新调度之前可能会更长。
互斥体
Mutex 就像是 C# 中的 lock,但可以跨多个进程工作。换句话说,Mutex 可以是全局的,也可以是应用程序级的。获取和释放一个没有争用的 Mutex 大约需要半微秒,比 lock 慢 20 倍。
使用 Mutex 类,你调用 WaitOne 方法来锁定,ReleaseMutex 来解锁。与 lock 语句一样,只能从获取它的同一线程中释放 Mutex。
注意
如果忘记调用ReleaseMutex,而只是调用Close或Dispose,其他等待该互斥体的人会抛出AbandonedMutexException异常。
跨进程使用的Mutex的常见用途是确保程序一次只能运行一个实例。以下是实现方式:
// Naming a Mutex makes it available computer-wide. Use a name that's
// unique to your company and application (e.g., include your URL).
using var mutex = new Mutex (true, @"Global\oreilly.com OneAtATimeDemo");
// Wait a few seconds if contended, in case another instance
// of the program is still in the process of shutting down.
if (!mutex.WaitOne (TimeSpan.FromSeconds (3), false))
{
Console.WriteLine ("Another instance of the app is running. Bye!");
return;
}
try { RunProgram(); }
finally { mutex.ReleaseMutex (); }
void RunProgram()
{
Console.WriteLine ("Running. Press Enter to exit");
Console.ReadLine();
}
注意
如果在终端服务或单独的 Unix 控制台下运行,计算机范围的Mutex通常只对同一会话中的应用程序可见。要使其对所有终端服务器会话可见,请在名称前加上*Global*,如示例中所示。
加锁与线程安全
如果程序或方法能够在任何多线程场景下正确工作,则它是线程安全的。通过加锁和减少线程交互的可能性来实现线程安全。
一般用途的类型完全不会是线程安全的,原因如下:
-
在完全实现线程安全时的开发负担可能很大,特别是如果一个类型有很多字段(每个字段都是在任意多线程环境中进行交互的潜在因素)。
-
线程安全可能会带来性能开销(部分是由于类型是否真正被多线程使用而产生的)。
-
线程安全的类型并不一定能使使用它的程序线程安全,而通常后者所涉及的工作会使前者变得多余。
因此,线程安全通常只在需要处理特定多线程场景时实现。
然而,在多线程环境中运行大型和复杂类的几种“作弊”方法。其中一种方法是通过包装大段代码(甚至整个对象的访问)在单个独占锁中执行,强制高级别的序列化访问。事实上,如果你想在多线程环境中使用线程不安全的第三方代码(或大多数.NET 类型),这种策略是必不可少的。关键是简单地使用相同的独占锁来保护对线程不安全对象的所有属性、方法和字段的访问。如果对象的方法都执行得很快,这种解决方案效果很好(否则将会有很多阻塞发生)。
注意
除了基本类型之外,很少有.NET 类型在实例化时对于任何更多于并发只读访问是线程安全的。开发者需要通过独占锁来实现线程安全(我们在第二十二章中介绍的System.Collections.Concurrent集合是一个例外)。
另一种欺骗的方法是通过最小化线程交互来最小化共享数据。这是一种极好的方法,隐式地在“无状态”中间层应用程序和网页服务器中使用。因为多个客户端请求可能同时到达,所以它们调用的服务器方法必须是线程安全的。无状态设计(由于可伸缩性原因而受欢迎)本质上限制了交互的可能性,因为类在请求之间不保存数据。然后,线程交互仅限于您可能选择创建的静态字段,用于在内存中缓存常用数据并提供诸如身份验证和审计等基础设施服务。
另一种解决方案(在富客户端应用程序中)是在 UI 线程上运行访问共享状态的代码。正如我们在第十四章中看到的那样,异步函数使这变得容易。
线程安全和.NET 类型
您可以使用锁定将线程不安全的代码转换为线程安全的代码。一个很好的应用是.NET:几乎所有非原始类型在实例化时都不是线程安全的(超过只读访问),但是如果所有对任何给定对象的访问都通过锁保护,则可以在多线程代码中使用它们。以下是一个例子,在该例子中,两个线程同时向相同的List集合添加项目,然后枚举该列表:
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类确实是线程安全的,并且我们想要向列表添加一个项目:
if (!_list.Contains (newItem)) _list.Add (newItem);
无论列表是否线程安全,这个语句肯定不是!整个if语句都需要包装在锁中,以防止在测试容器和添加新项目之间被抢占。然后,同一把锁将需要在修改列表的任何地方使用。例如,以下语句也需要包装在相同的锁中,以确保它不会抢占前一个语句:
_list.Clear();
换句话说,我们需要像我们的线程不安全集合类一样进行锁定(使List类的假设线程安全性变得多余)。
注意
在访问集合时进行锁定可能会在高度并发的环境中导致过多的阻塞。为此,.NET 提供了线程安全的队列、栈和字典,我们在第二十二章中讨论过。
静态成员
在自定义锁周围包装对象的访问仅在所有并发线程都知道并使用该锁时才有效。如果对象的范围广泛,这可能不是情况。最糟糕的情况是公共类型中的静态成员。例如,想象一下如果DateTime 结构上的静态属性 DateTime.Now 不是线程安全的,并且两个并发调用可能导致混乱的输出或异常。唯一修复此问题的外部锁可能是在调用 DateTime.Now 之前锁定类型本身 — lock(typeof(DateTime))。只有当所有程序员同意执行此操作时才会起作用(这是不太可能的)。此外,在锁定类型本身时会创建自身的问题。
由于这个原因,DateTime 结构上的静态成员被精心设计为线程安全。这是.NET 中的一个常见模式:静态成员是线程安全的;实例成员则不是。 遵循这个模式在为公共使用编写类型时也是有意义的,以免造成不可能的线程安全困境。换句话说,通过使静态方法线程安全,你在编程时不会排除那些类型的使用者的线程安全性。
注意
静态方法中的线程安全性是你必须显式编码的内容:它不会因为方法是静态而自动发生!
只读线程安全性
使类型对于并发只读访问(可能的情况下)是有利的,因为这意味着消费者可以避免过多的锁定。许多.NET 类型遵循这一原则:例如,集合对于并发读取是线程安全的。
遵循这个原则本身很简单:如果你将一个类型标记为对于并发只读访问是线程安全的,请不要在消费者期望是只读的方法内写入字段(或在其周围进行锁定)。例如,在集合中实现 ToArray() 方法时,你可能会首先压缩集合的内部结构。然而,这会使得消费者期望该方法是只读的时候线程不安全。
只读线程安全性是枚举器与“可枚举对象”分离的原因之一:两个线程可以同时枚举一个集合,因为每个线程都获得一个单独的枚举器对象。
注意
在没有文档的情况下,假设一个方法的性质是只读的是值得谨慎的。一个很好的例子是 Random 类:当你调用 Random.Next() 时,其内部实现需要更新私有种子值。因此,你必须要么在使用 Random 类时进行锁定,要么为每个线程保持单独的实例。
应用服务器中的线程安全性
应用服务器需要是多线程的以处理同时的客户端请求。ASP.NET Core 和 Web API 应用程序隐式地是多线程的。这意味着在服务器端编写代码时,如果可能有线程之间的交互,则必须考虑线程安全性。幸运的是,这种可能性很少;典型的服务器类要么是无状态的(没有字段),要么具有为每个客户端或每个请求创建单独对象实例的激活模型。交互通常仅通过静态字段产生,有时用于缓存数据库内存部分以提高性能。
例如,假设您有一个RetrieveUser方法用于查询数据库:
// User is a custom class with fields for user data
internal User RetrieveUser (int id) { ... }
如果此方法经常被调用,可以通过将结果缓存到静态的Dictionary中来提高性能。以下是一个概念上简单的解决方案,考虑了线程安全性:
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 from database;
lock (_users) _users [id] = u;
return u;
}
}
至少我们必须在读取和更新字典时进行锁定,以确保线程安全性。在这个例子中,我们在简单性和锁定性能之间选择了一个实际的折衷方案。我们的设计会造成小小的低效性:如果两个线程同时使用相同的先前未检索到的id调用此方法,RetrieveUser方法将被调用两次,字典将被不必要地更新。在整个方法内部进行一次锁定将防止这种情况发生,但会造成更糟糕的低效性:整个缓存在调用RetrieveUser期间将被锁定,期间其他线程将被阻塞在检索任何用户。
对于一个理想的解决方案,我们需要使用我们在“同步完成”中描述的策略。我们不是缓存User,而是缓存Task<User>,然后调用者等待它:
static class UserCache
{
static Dictionary <int, Task<User>> _userTasks =
new Dictionary <int, Task<User>>();
internal static Task<User> GetUserAsync (int id)
{
lock (_userTasks)
if (_userTasks.TryGetValue (id, out var userTask))
return userTask;
else
return _userTasks [id] = Task.Run (() => RetrieveUser (id));
}
}
请注意,我们现在有一个覆盖整个方法逻辑的单个锁。我们可以做到这一点而不影响并发性,因为在锁内部我们只是访问字典,并且(可能)启动一个异步操作(通过调用Task.Run)。如果两个线程同时使用相同的 ID 调用此方法,它们将都等待相同的任务,这正是我们想要的结果。
不可变对象
不可变对象是一种状态不可更改的对象—无论是外部还是内部。不可变对象中的字段通常声明为只读,并在构造期间完全初始化。
不可变性是函数式编程的标志,它不是变异对象,而是创建具有不同属性的新对象。LINQ 遵循这一范式。在多线程环境中,不可变性也是有价值的,因为它避免了共享可写状态的问题,通过消除(或最小化)可写状态。
一种模式是使用不可变对象封装一组相关字段,以最小化锁定持续时间。举个非常简单的例子,假设我们有两个字段,如下所示:
int _percentComplete;
string _statusMessage;
现在假设我们希望以原子方式读取和写入它们。与其在这些字段周围加锁不如定义以下不可变类:
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;
}
}
然后我们可以定义一个该类型的单一字段,以及一个锁对象:
readonly object _statusLocker = new object();
ProgressStatus _status;
现在,我们可以在不超过单个赋值的情况下读取和写入该类型的值了:
var status = new ProgressStatus (50, "Working on it");
// Imagine we were assigning many more fields...
// ...
lock (_statusLocker) _status = status; // Very brief lock
要读取对象,我们首先获取对象引用的副本(在锁内)。然后,我们可以读取其值而不需要保持锁定:
ProgressStatus status;
lock (_statusLocker) status = _status; // Again, a brief lock
int pc = status.PercentComplete;
string msg = status.StatusMessage;
...
非排他锁定
非排他锁定构造用于限制并发。在本节中,我们介绍信号量和读/写锁,并演示了SemaphoreSlim类如何通过异步操作限制并发。
信号量
一个信号量就像一个有限容量的夜总会,由门卫强制执行。当夜总会满员时,不能再进入更多人,外面就会排起队伍。
信号量的计数对应于夜总会中的空位数。释放信号量增加计数;这通常发生在有人离开夜总会(对应于释放资源)或者初始化信号量时(设置其起始容量)。您也可以随时调用Release来增加容量。
在信号量上等待会减少计数,通常发生在获取资源之前。在信号量当前计数大于0时,调用Wait会立即完成。
信号量可以选择性地有一个最大计数,作为硬限制。将计数增加到超过此限制会引发异常。在构造信号量时,您指定初始计数(起始容量),以及可选的最大限制。
初始计数为一的信号量类似于Mutex或lock,但信号量没有“所有者”——它是线程不可知的。任何线程都可以在信号量上调用Release,而在Mutex和lock中,只有获得锁的线程才能释放它。
注意
这个类有两个功能上类似的版本:Semaphore和SemaphoreSlim。后者已经经过优化,以满足并行编程的低延迟需求。它在传统的多线程编程中也很有用,因为它允许在等待时指定取消令牌(见“取消”),并且暴露了一个WaitAsync方法用于异步编程。但是,您不能用它来进行进程间信号传递。
Semaphore调用WaitOne和Release大约需要一微秒;SemaphoreSlim则大约需要十分之一微秒。
信号量在限制并发方面非常有用——防止太多线程同时执行特定的代码片段。在以下示例中,五个线程试图进入一个一次只允许三个线程的夜总会:
class TheClub // No door lists!
{
static SemaphoreSlim _sem = new SemaphoreSlim (3); // Capacity of 3
static void Main()
{
for (int i = 1; i <= 5; i++) new Thread (Enter).Start (i);
}
static void Enter (object id)
{
Console.WriteLine (id + " wants to enter");
_sem.Wait();
Console.WriteLine (id + " is in!"); // Only three threads
Thread.Sleep (1000 * (int) id); // can be here at
Console.WriteLine (id + " is leaving"); // a time.
_sem.Release();
}
}
1 wants to enter
1 is in!
2 wants to enter
2 is in!
3 wants to enter
3 is in!
4 wants to enter
5 wants to enter
1 is leaving
4 is in!
2 is leaving
5 is in!
也可以合法地用初始计数(容量)为 0 来实例化一个信号量,然后调用Release来增加其计数。以下两个信号量是等价的:
var semaphore1 = new SemaphoreSlim (3);
var semaphore2 = new SemaphoreSlim (0); semaphore2.Release (3);
一个Semaphore,如果命名,可以像Mutex一样跨进程工作(命名的Semaphore仅在 Windows 上可用,而命名的Mutex也适用于 Unix 平台)。
异步信号量和锁
跨越await语句进行锁定是非法的:
lock (_locker)
{
await Task.Delay (1000); // Compilation error
...
}
这样做是没有意义的,因为锁由一个线程持有,在从等待返回时通常会更改。锁定还会阻塞,并且阻塞可能很长时间,这正是您在异步函数中尝试不实现的。
然而,有时仍然希望使异步操作按顺序执行——或者限制并行性,以便不超过n个操作同时执行。例如,考虑一个 Web 浏览器:它需要并行执行异步下载,但可能希望强加限制,最多同时进行 10 个下载。我们可以通过使用SemaphoreSlim来实现这一点:
SemaphoreSlim _semaphore = new SemaphoreSlim (10);
async Task<byte[]> DownloadWithSemaphoreAsync (string uri)
{
await _semaphore.WaitAsync();
try { return await new WebClient().DownloadDataTaskAsync (uri); }
finally { _semaphore.Release(); }
}
将信号量的initialCount减少到1会将最大并行性减少为 1,将其转换为异步锁。
编写一个 EnterAsync 扩展方法
以下扩展方法通过使用我们在“匿名处理”中编写的Disposable类简化了SemaphoreSlim的异步使用:
public static async Task<IDisposable> EnterAsync (this SemaphoreSlim ss)
{
await ss.WaitAsync().ConfigureAwait (false);
return Disposable.Create (() => ss.Release());
}
有了这种方法,我们可以将我们的DownloadWithSemaphoreAsync方法重写如下:
async Task<byte[]> DownloadWithSemaphoreAsync (string uri)
{
using (await _semaphore.EnterAsync())
return await new WebClient().DownloadDataTaskAsync (uri);
}
Parallel.ForEachAsync
从.NET 6 开始,另一种限制异步并发性的方法是使用Parallel.ForEachAsync方法。假设uris是您希望下载的 URI 数组,以下是如何并行下载它们,同时将并发性限制为最多 10 个并行下载:
await Parallel.ForEachAsync (uris,
new ParallelOptions { MaxDegreeOfParallelism = 10 },
async (uri, cancelToken) =>
{
var download = await new HttpClient().GetByteArrayAsync (uri);
Console.WriteLine ($"Downloaded {download.Length} bytes");
});
Parallel 类中的其他方法旨在(计算密集型)并行编程场景中使用,我们在第二十二章中描述了这些场景。
读者/写者锁
很多时候,一个类型的实例对于并发读取操作是线程安全的,但对于并发更新(或并发读取和更新)是不安全的。这在像文件这样的资源上也可能是真实的。虽然使用简单的独占锁保护此类类型的实例通常可以达到预期的效果,但如果有许多读取者和偶尔的更新者,则可能会不合理地限制并发性。一个例子是在业务应用服务器中,常用数据被缓存以便在静态字段中快速检索。ReaderWriterLockSlim类专为在这种场景中提供最大可用性的锁定而设计。
注意
ReaderWriterLockSlim是旧的“fat”ReaderWriterLock类的替代品。后者在功能上类似,但速度慢几倍,并且在处理锁升级的机制上存在设计缺陷。
与普通的lock(Monitor.Enter/Exit)相比,ReaderWriterLockSlim仍然慢两倍。权衡是更少的竞争(当有大量读取和最小的写入时)。
对于这两个类,有两种基本类型的锁——读锁和写锁:
-
写锁是互斥的。
-
读锁与其他读锁兼容。
因此,持有写锁的线程会阻止所有试图获取读锁或写锁的其他线程(反之亦然)。但是,如果没有线程持有写锁,则任意数量的线程可以同时获取读锁。
ReaderWriterLockSlim定义了以下用于获取和释放读/写锁的方法:
public void EnterReadLock();
public void ExitReadLock();
public void EnterWriteLock();
public void ExitWriteLock();
此外,所有Enter*XXX*方法都有相应的“Try”版本,接受超时参数,类似于Monitor.TryEnter(如果资源争用严重,很容易出现超时)。 ReaderWriterLock提供类似的方法,名为Acquire*XXX*和Release*XXX*。如果超时发生,这些方法会抛出ApplicationException,而不是返回false。
以下程序演示了ReaderWriterLockSlim。三个线程不断枚举列表,而另外两个线程每 100 毫秒向列表追加一个随机数。读锁保护列表读取器,写锁保护列表写入器:
class SlimDemo
{
static ReaderWriterLockSlim _rw = new ReaderWriterLockSlim();
static List<int> _items = new List<int>();
static Random _rand = new Random();
static void Main()
{
new Thread (Read).Start();
new Thread (Read).Start();
new Thread (Read).Start();
new Thread (Write).Start ("A");
new Thread (Write).Start ("B");
}
static void Read()
{
while (true)
{
_rw.EnterReadLock();
foreach (int i in _items) Thread.Sleep (10);
_rw.ExitReadLock();
}
}
static void Write (object threadID)
{
while (true)
{
int newNumber = GetRandNum (100);
_rw.EnterWriteLock();
_items.Add (newNumber);
_rw.ExitWriteLock();
Console.WriteLine ("Thread " + threadID + " added " + newNumber);
Thread.Sleep (100);
}
}
static int GetRandNum (int max) { lock (_rand) return _rand.Next(max); }
}
注意
在生产代码中,通常会添加try/finally块,以确保在抛出异常时释放锁。
这是结果:
Thread B added 61
Thread A added 83
Thread B added 55
Thread A added 33
...
ReaderWriterLockSlim允许比简单锁更多的并发Read活动。我们可以通过在Write方法的while循环开始处插入以下行来说明这一点:
Console.WriteLine (_rw.CurrentReadCount + " concurrent readers");
这几乎总是打印“3 个并发读取器”(Read方法大部分时间在其foreach循环内)。除了CurrentReadCount,ReaderWriterLockSlim还提供了以下用于监视锁的属性:
public bool IsReadLockHeld { get; }
public bool IsUpgradeableReadLockHeld { get; }
public bool IsWriteLockHeld { get; }
public int WaitingReadCount { get; }
public int WaitingUpgradeCount { get; }
public int WaitingWriteCount { get; }
public int RecursiveReadCount { get; }
public int RecursiveUpgradeCount { get; }
public int RecursiveWriteCount { get; }
升级锁
有时,将读锁替换为写锁在单个原子操作中是有用的。例如,假设您希望仅在列表中不存在该项时才将项添加到列表中。理想情况下,您希望尽量减少持有(独占)写锁的时间,因此可以按以下步骤进行:
-
获取读锁。
-
测试列表中是否已经存在该项;如果是,则释放锁并
return。 -
释放读锁。
-
获取写锁。
-
添加该项。
问题在于,在步骤 3 和步骤 4 之间,另一个线程可能会悄悄地修改列表(例如,添加相同的项)。 ReaderWriterLockSlim通过第三种称为升级锁的锁来解决这个问题。升级锁类似于读锁,但稍后可以以原子操作升级为写锁。以下是如何使用它的方法:
-
调用
EnterUpgradeableReadLock。 -
执行基于读的活动(例如,测试列表中是否已经存在该项)。
-
调用
EnterWriteLock(这将升级锁为写锁)。 -
执行基于写的活动(例如,将项添加到列表中)。
-
调用
ExitWriteLock(这将写锁转换回升级锁)。 -
执行任何其他基于读取的活动。
-
调用
ExitUpgradeableReadLock。
从调用者的角度来看,这更像是嵌套或递归锁定。 但在功能上,在第 3 步中,ReaderWriterLockSlim释放您的读锁并原子性地获取一个新的写锁。
读锁和可升级锁之间还有一个重要的区别。 虽然可升级锁可以与任意数量的读锁共存,但每次只能获取一个可升级锁。 这通过串行化竞争转换来防止转换死锁,就像 SQL Server 中的更新锁一样:
| SQL Server | ReaderWriterLockSlim |
|---|---|
| 共享锁 | 读锁 |
| 独占锁 | 写锁 |
| 更新锁 | 可升级锁 |
我们可以通过更改前面示例中的Write方法来演示可升级锁,只有在列表中不存在时才添加一个数字:
while (true)
{
int newNumber = GetRandNum (100);
_rw.EnterUpgradeableReadLock();
if (!_items.Contains (newNumber))
{
_rw.EnterWriteLock();
_items.Add (newNumber);
_rw.ExitWriteLock();
Console.WriteLine ("Thread " + threadID + " added " + newNumber);
}
_rw.ExitUpgradeableReadLock();
Thread.Sleep (100);
}
注意
ReaderWriterLock也可以进行锁定转换,但不可靠,因为它不支持可升级锁的概念。 这就是为什么ReaderWriterLockSlim的设计者们不得不重新开始用一个新的类的原因。
锁定递归
通常,使用ReaderWriterLockSlim禁止嵌套或递归锁定。 因此,以下操作会引发异常:
var rw = new ReaderWriterLockSlim();
rw.EnterReadLock();
rw.EnterReadLock();
rw.ExitReadLock();
rw.ExitReadLock();
如果您按以下方式构造ReaderWriterLockSlim,它将无错误运行:
var rw = new ReaderWriterLockSlim (LockRecursionPolicy.SupportsRecursion);
这确保只有在计划中才能发生递归锁定。 递归锁定可能会产生不必要的复杂性,因为可能会获取多种类型的锁:
rw.EnterWriteLock();
rw.EnterReadLock();
Console.WriteLine (rw.IsReadLockHeld); // True
Console.WriteLine (rw.IsWriteLockHeld); // True
rw.ExitReadLock();
rw.ExitWriteLock();
基本规则是,一旦你获得了一个锁,后续的递归锁可以按以下规模减少,但不能增加:
读锁→可升级锁→写锁
但是,请求将可升级锁升级为写锁总是合法的。
使用事件等待句柄进行信号传递
最简单的信号构造被称为事件等待句柄(与 C#事件无关)。 事件等待句柄有三种类型:AutoResetEvent、ManualResetEvent(Slim)和CountdownEvent。 前两者基于常见的EventWaitHandle类,从中继承所有功能。
AutoResetEvent
AutoResetEvent类似于票证旋转门:插入一张票证允许一人通过。 类名中的“auto”指的是开放旋转门后自动关闭或“复位”的事实。 线程通过调用WaitOne(在这个“one”旋转门等待直到它打开)等待或阻塞在旋转门处,并通过调用Set方法插入票证。 如果多个线程调用WaitOne,则在旋转门后面会形成一个队列²。 票证可以来自任何线程;换句话说,任何(未阻塞)有权访问AutoResetEvent对象的线程都可以在其上调用Set以释放一个被阻塞的线程。
您可以通过两种方式创建AutoResetEvent。 第一种是通过其构造函数:
var auto = new AutoResetEvent (false);
(在构造函数中传入true相当于立即调用Set。)创建AutoResetEvent的第二种方式如下:
var auto = new EventWaitHandle (false, EventResetMode.AutoReset);
在以下示例中,启动了一个线程,其工作是等待另一个线程的信号(参见图 21-1):
class BasicWaitHandle
{
static EventWaitHandle _waitHandle = new AutoResetEvent (false);
static void Main()
{
new Thread (Waiter).Start();
Thread.Sleep (1000); // Pause for a second...
_waitHandle.Set(); // Wake up the Waiter.
}
static void Waiter()
{
Console.WriteLine ("Waiting...");
_waitHandle.WaitOne(); // Wait for notification
Console.WriteLine ("Notified");
}
}
// Output:
Waiting... *(pause)* Notified.
图 21-1. 使用EventWaitHandle进行信号
如果在没有线程等待时调用Set,则句柄保持打开状态,直到某个线程调用WaitOne。这种行为有助于防止一个线程走向旋转门,另一个线程插入票(“哎呀,票插得太早了,现在你将无限期地等待!”)。然而,在一个无人等待的旋转门上重复调用Set并不会在整个队伍到达时允许他们全部通过:只有下一个人可以通过,并且多余的票“被浪费”。
调用Reset关闭一个AutoResetEvent的旋转门(如果它是打开的),而不会等待或阻塞。
WaitOne接受一个可选的超时参数,如果由于超时而结束等待,则返回false而不是获取信号。
注
使用超时值0调用WaitOne可以测试等待句柄是否“打开”,而不会阻塞调用者。不过,请记住,如果已经打开,这样做会重置AutoResetEvent。
双向信号
假设我们希望主线程连续三次向工作线程发出信号。如果主线程快速连续调用等待句柄上的Set,则第二次或第三次信号可能会丢失,因为工作线程可能需要时间处理每个信号。
解决方案是主线程在向其发出信号之前等待工作线程就绪。我们可以通过使用另一个AutoResetEvent来实现:
class TwoWaySignaling
{
static EventWaitHandle _ready = new AutoResetEvent (false);
static EventWaitHandle _go = new AutoResetEvent (false);
static readonly object _locker = new object();
static string _message;
static void Main()
{
new Thread (Work).Start();
_ready.WaitOne(); // First wait until worker is ready
lock (_locker) _message = "ooo";
_go.Set(); // Tell worker to go
_ready.WaitOne();
lock (_locker) _message = "ahhh"; // Give the worker another message
_go.Set();
_ready.WaitOne();
lock (_locker) _message = null; // Signal the worker to exit
_go.Set();
}
static void Work()
{
while (true)
{
_ready.Set(); // Indicate that we're ready
_go.WaitOne(); // Wait to be kicked off...
lock (_locker)
{
if (_message == null) return; // Gracefully exit
Console.WriteLine (_message);
}
}
}
}
// Output:
ooo
ahhh
图 21-2 展示了这个过程。
图 21-2. 双向信号
在这里,我们使用空消息来指示工作线程应该结束。对于运行无限期的线程,拥有退出策略是很重要的!
ManualResetEvent
正如我们在第十四章中描述的那样,ManualResetEvent的功能类似于一个简单的门闩。调用Set打开门闩,允许调用WaitOne的任意数量线程通过。调用Reset关闭门闩。调用WaitOne在关闭状态下的门闩会阻塞;下次打开门闩时,它们将一次性释放。除了这些差异,ManualResetEvent的功能与AutoResetEvent类似。
与AutoResetEvent一样,可以通过两种方式构造ManualResetEvent:
var manual1 = new ManualResetEvent (false);
var manual2 = new EventWaitHandle (false, EventResetMode.ManualReset);
注
还有另一个称为ManualResetEventSlim的版本的ManualResetEvent。后者针对短等待时间进行了优化,具有可以选择自旋一定次数的能力。它还具有更有效的托管实现,并允许通过CancellationToken取消Wait。ManualResetEventSlim不继承自WaitHandle;但是,当调用时它公开一个WaitHandle属性,返回基于WaitHandle的对象(具有传统等待句柄的性能特征)。
ManualResetEvent在允许一个线程解除阻塞多个其他线程方面非常有用。相反的情况由CountdownEvent处理。
CountdownEvent
CountdownEvent允许您等待多个线程。该类具有高效的完全托管实现。要使用该类,请用您要等待的线程数或“计数”来实例化它:
var countdown = new CountdownEvent (3); // Initialize with "count" of 3.
调用Signal减少“计数”;调用Wait阻塞直到计数减少为零:
new Thread (SaySomething).Start ("I am thread 1");
new Thread (SaySomething).Start ("I am thread 2");
new Thread (SaySomething).Start ("I am thread 3");
countdown.Wait(); // Blocks until Signal has been called 3 times
Console.WriteLine ("All threads have finished speaking!");
void SaySomething (object thing)
{
Thread.Sleep (1000);
Console.WriteLine (thing);
countdown.Signal();
}
注意
有时您可以通过使用我们在第二十二章中描述的结构化并行构造(如 PLINQ 和Parallel类)更轻松地解决适合CountdownEvent的问题。
您可以通过调用AddCount重新增加CountdownEvent的计数。但是,如果它已经达到零,这将抛出异常:无法通过调用AddCount“取消”CountdownEvent。为防止抛出异常的可能性,您可以调用TryAddCount,如果倒计时为零,则返回false。
要取消标记一个倒计时事件,请调用Reset:这既取消了构造并将其计数重置为原始值。
像ManualResetEventSlim一样,CountdownEvent公开了一个WaitHandle属性,用于某些其他类或方法期望基于WaitHandle的对象的场景。
创建跨进程的 EventWaitHandle
EventWaitHandle的构造函数允许创建一个“命名”的EventWaitHandle,能够跨多个进程进行操作。名称只是一个字符串,可以是任何不会意外与他人冲突的值!如果计算机上已经使用了该名称,则会得到对同一基础EventWaitHandle的引用;否则,操作系统会创建一个新的。以下是一个示例:
EventWaitHandle wh = new EventWaitHandle (false, EventResetMode.AutoReset,
@"Global\MyCompany.MyApp.SomeName");
如果两个应用程序都运行此代码,则它们将能够互相发信号:等待句柄将跨所有线程在两个进程中运行。
命名事件等待句柄仅在 Windows 上可用。
等待句柄和继续
而不是等待等待句柄(并阻塞您的线程),您可以通过调用ThreadPool.RegisterWaitForSingleObject为其附加一个“继续”。此方法接受一个委托,在等待句柄被标记时执行:
var starter = new ManualResetEvent (false);
RegisteredWaitHandle reg = ThreadPool.RegisterWaitForSingleObject
(starter, Go, "Some Data", -1, true);
Thread.Sleep (5000);
Console.WriteLine ("Signaling worker...");
starter.Set();
Console.ReadLine();
reg.Unregister (starter); // Clean up when we’re done.
void Go (object data, bool timedOut)
{
Console.WriteLine ("Started - " + data);
// Perform task...
}
// Output:
(5 second delay)
Signaling worker...
Started - Some Data
当等待句柄被标记(或超时时间到达)时,委托在一个池线程上运行。然后,您应该调用Unregister来释放到回调的非托管句柄。
除了等待句柄和委托之外,RegisterWaitForSingleObject还接受一个“黑匣子”对象,它将其传递给你的委托方法(类似于ParameterizedThreadStart),以及一个毫秒级超时(-1表示无超时)和一个布尔标志,指示请求是一次性而非定期的。
注意
你只能可靠地对每个等待句柄调用一次RegisterWaitForSingleObject。在同一个等待句柄上再次调用此方法会导致间歇性失败,即使未信号化的等待句柄会像已信号化一样触发回调。
此限制使(非精简)等待句柄非常不适合异步编程。
WaitAll、WaitAny和SignalAndWait
除了Set、WaitOne和Reset方法之外,WaitHandle类上还有静态方法来解决更复杂的同步问题。WaitAny、WaitAll和SignalAndWait方法在多个句柄上执行信号和等待操作。等待句柄可以是不同类型的(包括Mutex和Semaphore,因为它们也从抽象的WaitHandle类派生)。通过它们的WaitHandle属性,ManualResetEventSlim和CountdownEvent也可以参与这些方法。
注意
WaitAll和SignalAndWait与传统的 COM 架构有一个奇怪的联系:这些方法要求调用者位于多线程公寓中,这种模型最不适合互操作性。例如,WPF 或 Windows Forms 应用程序的主线程无法在此模式下与剪贴板交互。我们很快将讨论替代方法。
WaitHandle.WaitAny在等待句柄数组中的任何一个句柄;WaitHandle.WaitAll原子地等待所有给定的句柄。这意味着如果你等待两个AutoResetEvent:
-
WaitAny永远不会“锁住”两个事件。 -
WaitAll永远不会“锁住”仅一个事件。
SignalAndWait在一个WaitHandle上调用Set,然后在另一个WaitHandle上调用WaitOne。在信号第一个句柄后,它将立即排队等待第二个句柄;这有助于它成功(尽管操作并非真正原子)。你可以将这种方法看作是在一对EventWaitHandle上“交换”一个信号以设置两个线程在同一时间点“会合”或“汇合”。任一AutoResetEvent或ManualResetEvent都能完成任务。第一个线程执行以下操作:
WaitHandle.SignalAndWait (wh1, wh2);
第二个线程执行相反操作:
WaitHandle.SignalAndWait (wh2, wh1);
替代方案替代WaitAll和SignalAndWait
WaitAll 和 SignalAndWait 不会在单线程公寓中运行。幸运的是,有替代方案。对于 SignalAndWait,很少需要其队列跳跃语义:例如,在我们的汇合示例中,如果仅仅是用等待句柄来实现,只需在第一个等待句柄上调用 Set,然后在其他等待句柄上调用 WaitOne 就足够了。在下一节中,我们将探讨另一种实现线程汇合的选项。
对于 WaitAny 和 WaitAll,如果不需要原子性,可以使用我们在前一节中编写的代码将等待句柄转换为任务,然后使用 Task.WhenAny 和 Task.WhenAll(第十四章)。
如果需要原子性,可以采用最低级别的信号处理方法,并使用 Monitor 的 Wait 和 Pulse 方法自行编写逻辑。我们在http://albahari.com/threading中详细描述了 Wait 和 Pulse。
Barrier 类
Barrier 类实现了线程执行屏障,允许多个线程在某个时间点汇合(与 Thread.MemoryBarrier 不要混淆)。该类非常快速高效,基于 Wait、Pulse 和自旋锁实现。
使用此类:
-
实例化它时,指定应参与汇合的线程数(您可以随后调用
AddParticipants/RemoveParticipants更改此值)。 -
每个线程在希望汇合时都要调用
SignalAndWait。
使用值为 3 实例化 Barrier 会导致 SignalAndWait 阻塞,直到该方法被调用三次。然后它重新开始:再次调用 SignalAndWait 将阻塞,直到再次调用三次。这使得每个线程与其他线程“同步”。
在以下示例中,三个线程分别写入数字 0 到 4,并与其他线程保持同步:
var barrier = new Barrier (3);
new Thread (Speak).Start();
new Thread (Speak).Start();
new Thread (Speak).Start();
void Speak()
{
for (int i = 0; i < 5; i++)
{
Console.Write (i + " ");
barrier.SignalAndWait();
}
}
OUTPUT: 0 0 0 1 1 1 2 2 2 3 3 3 4 4 4
Barrier 的一个非常有用的特性是在构造时还可以指定后阶段操作。这是一个在调用 SignalAndWait n 次后、但线程解除阻塞之前运行的委托(如 图 21-3 中显示的阴影区域)。例如,如果我们按以下方式实例化我们的屏障:
static Barrier _barrier = new Barrier (3, barrier => Console.WriteLine());
输出如下:
0 0 0
1 1 1
2 2 2
3 3 3
4 4 4
图 21-3. 屏障
后阶段操作对于合并来自每个工作线程的数据非常有用。它不需要担心抢占,因为所有工作线程在其执行期间都会被阻塞。
惰性初始化
线程中常见的问题是如何以线程安全的方式惰性初始化共享字段。当需要构造成本高昂的类型字段时就会出现这种需求:
class Foo
{
public readonly Expensive Expensive = new Expensive();
...
}
class Expensive { /* Suppose this is expensive to construct */ }
此代码的问题在于实例化 Foo 会导致实例化 Expensive 的性能成本,无论是否实际访问了 Expensive 字段。显而易见的答案是按需构造实例:
class Foo
{
Expensive _expensive;
public Expensive Expensive // *Lazily* instantiate Expensive
{
get
{
if (_expensive == null) _expensive = new Expensive();
return _expensive;
}
}
...
}
那么问题来了,这样做是否线程安全?除了我们在没有内存屏障的情况下在锁之外访问_expensive之外,考虑一下如果两个线程同时访问这个属性会发生什么。它们都可能满足if语句的谓词,每个线程最终都会得到一个不同的Expensive实例。因为这可能导致微妙的错误,我们通常会说,总的来说,这段代码是不线程安全的。
解决问题的方法是在检查和初始化对象周围加锁:
Expensive _expensive;
readonly object _expenseLock = new object();
public Expensive Expensive
{
get
{
lock (_expenseLock)
{
if (_expensive == null) _expensive = new Expensive();
return _expensive;
}
}
}
Lazy
Lazy<T>类可用于帮助进行延迟初始化。如果使用true参数实例化它,它实现了刚刚描述的线程安全初始化模式。
注意
Lazy<T>实际上实现了这种模式的微优化版本,称为双重检查锁定。双重检查锁定执行额外的 volatile 读取,以避免在对象已初始化时获取锁的成本。
要使用Lazy<T>,用告诉它如何初始化新值的值工厂委托实例化该类,并使用参数true。然后,通过Value属性访问其值:
Lazy<Expensive> _expensive = new Lazy<Expensive>
(() => new Expensive(), true);
public Expensive Expensive { get { return _expensive.Value; } }
如果将false传递给Lazy<T>的构造函数,它将实现我们在本节开头描述的线程不安全的延迟初始化模式——当您想在单线程上下文中使用Lazy<T>时,这是合理的。
LazyInitializer
LazyInitializer是一个静态类,其工作方式与Lazy<T>完全相同,除了:
-
它的功能通过直接在自己类型的字段上操作的静态方法公开。这样可以避免一级间接,提高在需要极端优化的情况下的性能。
-
它提供了另一种初始化模式,其中多个线程可以竞速初始化。
要使用LazyInitializer,在访问字段之前调用EnsureInitialized,将字段的引用和工厂委托传递给它:
Expensive _expensive;
public Expensive Expensive
{
get // Implement double-checked locking
{
LazyInitializer.EnsureInitialized (ref _expensive,
() => new Expensive());
return _expensive;
}
}
您还可以传入另一个参数,请求竞争的线程竞速初始化。这听起来与我们原来的线程不安全的示例类似,不同之处在于第一个完成的线程总是获胜,因此最终只会得到一个实例。这种技术的优势在于,它甚至比双重检查锁定更快(在多核上),因为它可以完全不使用锁定,而是使用我们在“非阻塞同步”和“延迟初始化”中描述的先进技术。这是一种极端(而且很少需要的)优化,代价是:
-
当有更多的线程竞速初始化时,速度会变慢,超过了你的核心数。
-
这可能会浪费 CPU 资源执行冗余的初始化。
-
初始化逻辑必须是线程安全的(在这种情况下,如果
Expensive的构造函数写入静态字段,它将是线程不安全的)。 -
如果初始化器实例化一个需要处理的对象,没有额外逻辑的话,"浪费"的对象不会被处理。
线程本地存储
本章的大部分内容集中在同步构造和多线程同时访问相同数据带来的问题上。然而,有时您希望保持数据隔离,确保每个线程有自己的副本。局部变量正好实现了这一点,但它们仅适用于瞬态数据。
解决方案是线程本地存储。您可能难以想象一种要求:希望将数据隔离到线程的数据通常是短暂性的。它的主要应用是用于存储“带外”数据——支持执行路径基础设施的数据,如消息、事务和安全令牌。在方法参数中传递这样的数据可能会很笨拙,也可能会疏远除了您自己的方法之外的所有人;在普通的静态字段中存储此类信息意味着在所有线程之间共享它。
线程本地存储在优化并行代码方面也非常有用。它允许每个线程独占地访问其自己的版本的线程不安全对象,而无需锁定并且无需在方法调用之间重建该对象。
有四种实现线程本地存储的方法。我们将在以下小节中看一下它们。
[ThreadStatic]
最简单的线程本地存储方法是使用ThreadStatic属性标记静态字段:
[ThreadStatic] static int _x;
每个线程都会看到_x的一个独立副本。
不幸的是,[ThreadStatic]对实例字段无效(它仅仅不起作用);它也无法很好地与字段初始化器配合使用——它们只在执行静态构造函数时在当前运行的线程上执行一次。如果需要操作实例字段或者以非默认值开始,ThreadLocal<T>提供了更好的选择。
ThreadLocal<T>
ThreadLocal<T>为静态和实例字段提供了线程本地存储,并允许您指定默认值。
下面是如何为每个线程创建一个默认值为3的ThreadLocal<int>:
static ThreadLocal<int> _x = new ThreadLocal<int> (() => 3);
然后,您可以使用_x的Value属性获取或设置其线程本地值。使用ThreadLocal的一个额外好处是值的延迟评估:工厂函数在第一次调用时进行评估(对于每个线程)。
ThreadLocal<T>和实例字段
ThreadLocal<T>在处理实例字段和捕获的局部变量时也非常有用。例如,考虑在多线程环境中生成随机数的问题。Random类不是线程安全的,因此我们必须在使用Random时要么加锁(限制并发性),要么为每个线程生成一个独立的Random对象。ThreadLocal<T>使后者变得简单:
var localRandom = new ThreadLocal<Random>(() => new Random());
Console.WriteLine (localRandom.Value.Next());
我们用于创建Random对象的工厂函数有点简单,因为Random的无参构造函数依赖于系统时钟以获取随机数种子。这可能导致在相隔不到10 ms内创建的两个Random对象使用相同的种子。以下是解决此问题的一种方法:
var localRandom = new ThreadLocal<Random>
( () => new Random (Guid.NewGuid().GetHashCode()) );
我们在第二十二章中使用这个(请参阅“PLINQ”中的并行拼写检查示例)。
获取数据和设置数据
第三种方法是使用Thread类中的两个方法:GetData和SetData。它们将数据存储在特定于线程的“槽位”中。Thread.GetData从线程的隔离数据存储中读取;Thread.SetData向其写入。这两个方法都需要一个LocalDataStoreSlot对象来标识槽位。您可以在所有线程中使用相同的槽位,并且它们仍然会得到不同的值。以下是一个示例:
class Test
{
// The same LocalDataStoreSlot object can be used across all threads.
LocalDataStoreSlot _secSlot = Thread.GetNamedDataSlot ("securityLevel");
// This property has a separate value on each thread.
int SecurityLevel
{
get
{
object data = Thread.GetData (_secSlot);
return data == null ? 0 : (int) data; // null == uninitialized
}
set { Thread.SetData (_secSlot, value); }
}
...
在这个实例中,我们调用了Thread.GetNamedDataSlot,它创建了一个命名的槽位——这允许在整个应用程序中共享该槽位。或者,您可以通过调用Thread.AllocateDataSlot来自行控制槽位的作用域:
class Test
{
LocalDataStoreSlot _secSlot = Thread.AllocateDataSlot();
...
Thread.FreeNamedDataSlot将释放跨所有线程的命名数据槽,但仅当该LocalDataStoreSlot的所有引用都已经超出范围并且已被垃圾收集时。这确保线程在需要时保持对适当LocalDataStoreSlot对象的引用,以免槽位被废弃。
异步本地
到目前为止,我们讨论的线程本地存储方法与异步函数不兼容,因为在await之后,执行可能会在不同的线程上恢复。AsyncLocal<T>类通过在await期间保留其值来解决这个问题:
static AsyncLocal<string> _asyncLocalTest = new AsyncLocal<string>();
async void Main()
{
_asyncLocalTest.Value = "test";
await Task.Delay (1000);
// The following works even if we come back on another thread:
Console.WriteLine (_asyncLocalTest.Value); // test
}
AsyncLocal<T>仍然能够区分在不同线程上启动的操作,无论是由Thread.Start还是Task.Run发起的。以下写入“one one”和“two two”:
static AsyncLocal<string> _asyncLocalTest = new AsyncLocal<string>();
void Main()
{
// Call Test twice on two concurrent threads:
new Thread (() => Test ("one")).Start();
new Thread (() => Test ("two")).Start();
}
async void Test (string value)
{
_asyncLocalTest.Value = value;
await Task.Delay (1000);
Console.WriteLine (value + " " + _asyncLocalTest.Value);
}
AsyncLocal<T>有一个有趣且独特的细微差别:如果在线程启动时AsyncLocal<T>对象已经有一个值,新线程将“继承”该值:
static AsyncLocal<string> _asyncLocalTest = new AsyncLocal<string>();
void Main()
{
_asyncLocalTest.Value = "test";
new Thread (AnotherMethod).Start();
}
void AnotherMethod() => Console.WriteLine (_asyncLocalTest.Value); // test
然而,新线程得到的是值的副本,因此它所做的任何更改都不会影响原始值:
static AsyncLocal<string> _asyncLocalTest = new AsyncLocal<string>();
void Main()
{
_asyncLocalTest.Value = "test";
var t = new Thread (AnotherMethod);
t.Start(); t.Join();
Console.WriteLine (_asyncLocalTest.Value); // test (not ha-ha!)
}
void AnotherMethod() => _asyncLocalTest.Value = "ha-ha!";
请记住,新线程得到的是值的浅拷贝。因此,如果您将Async<string>替换为Async<StringBuilder>或Async<List<string>>,新线程可能会清除StringBuilder或向List<string>添加/删除项目,并且这将影响原始值。
定时器
如果您需要定期在一定间隔内重复执行某个方法,最简单的方法是使用定时器。定时器在内存和资源的使用上非常方便和高效——相比于以下技术:
new Thread (delegate() {
while (*enabled*)
{
*DoSomeAction*();
Thread.Sleep (TimeSpan.FromHours (24));
}
}).Start();
这不仅会永久地占用一个线程资源,而且在没有额外编码的情况下,DoSomeAction将每天稍后的某个时间发生。定时器解决了这些问题。
.NET 提供了五种定时器。其中两种是通用多线程定时器:
-
System.Threading.Timer -
System.Timers.Timer
另外两个是特定用途的单线程定时器:
-
System.Windows.Forms.Timer(Windows Forms 定时器) -
System.Windows.Threading.DispatcherTimer(WPF 定时器)
多线程定时器更强大、精确和灵活;单线程定时器更安全,更适合运行更新 Windows Forms 控件或 WPF 元素的简单任务。
最后,从 .NET 6 开始,有 PeriodicTimer,我们将首先介绍它。
PeriodicTimer
PeriodicTimer 实际上并不是一个定时器;它是一个类,用于简化异步循环。考虑到 async 和 await 的出现,传统定时器通常不再必要。相反,以下模式效果良好:
StartPeriodicOperation();
async void StartPeriodicOperation()
{
while (true)
{
await Task.Delay (1000);
Console.WriteLine ("Tick"); // Do some action
}
}
注意
如果从 UI 线程调用 StartPeriodicOperation,它将表现为单线程定时器,因为 await 将始终返回相同的同步上下文。
只需在 await 后添加 .ConfigureAwait(false),即可使其表现为多线程定时器。
PeriodicTimer 是一个用于简化此模式的类:
var timer = new PeriodicTimer (TimeSpan.FromSeconds (1));
StartPeriodicOperation();
// Optionally dispose timer when you want to stop looping.
async void StartPeriodicOperation()
{
while (await timer.WaitForNextTickAsync())
Console.WriteLine ("Tick"); // Do some action
}
PeriodicTimer 还允许您通过释放定时器实例来停止定时器。这将导致 WaitForNextTickAsync 返回 false,从而结束循环。
多线程定时器
System.Threading.Timer 是最简单的多线程定时器:它只有一个构造函数和两个方法(对极简主义者和书籍作者来说是一种乐事!)。在以下示例中,定时器调用 Tick 方法,在五秒钟后写入“tick...”,然后每秒执行一次,直到用户按下 Enter:
using System;
using System.Threading;
// First interval = 5000ms; subsequent intervals = 1000ms
Timer tmr = new Timer (Tick, "tick...", 5000, 1000);
Console.ReadLine();
tmr.Dispose(); // This both stops the timer and cleans up.
void Tick (object data)
{
// This runs on a pooled thread
Console.WriteLine (data); // Writes "tick..."
}
注意
参见“定时器”,讨论如何处理释放多线程定时器的问题。
通过调用其 Change 方法,您可以稍后更改定时器的间隔。如果要使定时器仅触发一次,请在构造函数的最后一个参数中指定 Timeout.Infinite。
.NET 在 System.Timers 命名空间中提供了另一个同名定时器类。它简单地包装了 System.Threading.Timer,在使用相同的底层引擎的同时提供了额外的便利性。以下是其新增功能的摘要:
-
实现
IComponent接口,允许在 Visual Studio 的设计器组件托盘中设置 -
Interval属性而非Change方法 -
使用
Elapsed事件 而非回调委托 -
Enabled属性用于启动和停止定时器(默认值为false) -
Start和Stop方法,以防你对Enabled感到困惑 -
一个
AutoReset标志用于指示重复事件(默认值为true) -
SynchronizingObject属性,具有Invoke和BeginInvoke方法,用于安全地调用 WPF 元素和 Windows Forms 控件的方法
以下是一个示例:
using System;
using System.Timers; // Timers namespace rather than Threading
var tmr = new Timer(); // Doesn't require any args
tmr.Interval = 500;
tmr.Elapsed += tmr_Elapsed; // Uses an event instead of a delegate
tmr.Start(); // Start the timer
Console.ReadLine();
tmr.Stop(); // Stop the timer
Console.ReadLine();
tmr.Start(); // Restart the timer
Console.ReadLine();
tmr.Dispose(); // Permanently stop the timer
void tmr_Elapsed (object sender, EventArgs e)
=> Console.WriteLine ("Tick");
多线程定时器使用线程池来服务多个定时器。这意味着每次调用回调方法或 Elapsed 事件时,它可能在不同的线程上触发。此外,Elapsed 事件始终(大致)按时触发,不受前一个 Elapsed 事件是否执行完成的影响。因此,回调或事件处理程序必须是线程安全的。
多线程定时器的精度取决于操作系统,通常在 10 到 20 毫秒范围内。如果需要更高的精度,可以使用本地 Interop 并调用 Windows 多媒体定时器。这种定时器的精度可达到一毫秒,定义在winmm.dll中。首先调用timeBeginPeriod通知操作系统您需要高时序精度,然后调用timeSetEvent启动多媒体定时器。完成后,调用timeKillEvent停止定时器,并调用timeEndPeriod通知操作系统您不再需要高时序精度。第二十四章展示了如何使用 P/Invoke 调用外部方法。您可以通过搜索关键字dllimport winmm.dll timesetevent在互联网上找到使用多媒体定时器的完整示例。
单线程定时器
.NET 提供了专为消除 WPF 和 Windows Forms 应用程序的线程安全问题而设计的定时器:
-
System.Windows.Threading.DispatcherTimer(WPF) -
System.Windows.Forms.Timer(Windows Forms)
注
单线程定时器不设计用于其各自的环境之外。例如,如果在 Windows 服务应用程序中使用 Windows Forms 定时器,则Timer事件不会触发!
两者在公开的成员(如Interval、Start和Stop,以及等效于Elapsed的Tick)方面类似于System.Timers.Timer,并且使用方式类似。然而,它们在内部工作方式上有所不同。它们不是在池化线程上触发计时器事件,而是将事件发布到 WPF 或 Windows Forms 消息循环中。这意味着Tick事件始终在最初创建计时器的同一线程上触发——在正常应用程序中,这是用于管理所有用户界面元素和控件的同一线程。这带来了许多好处:
-
您可以忘记线程安全性。
-
新的
Tick将不会触发,直到前一个Tick处理完毕。 -
可以直接从
Tick事件处理代码更新用户界面元素和控件,无需调用Control.BeginInvoke或Dispatcher.BeginInvoke。
因此,使用这些定时器的程序实际上并非多线程:您最终会得到与第十四章中描述的伪并发相同的伪并发,其中异步函数在 UI 线程上执行。一个线程为所有定时器和处理 UI 事件服务,这意味着Tick事件处理程序必须快速执行,否则 UI 会变得无响应。
这使得 WPF 和 Windows Forms 定时器适用于小型任务,通常用于更新 UI 的某些方面(例如时钟或倒计时显示)。
在精度方面,单线程定时器与多线程定时器类似(数十毫秒),尽管它们通常不如后者准确,因为它们可能会因其他 UI 请求(或其他计时器事件)而延迟。
¹ Windows 和 CLR 行为的微妙之处意味着队列的公平性有时会受到侵犯。
² 与锁一样,由于操作系统的微妙之处,队列的公平性有时也会被侵犯。