最大限度地降低多线程 C# 代码的复杂性

简介: 最大限度地降低多线程 C# 代码的复杂性分支或多线程编程是编程时最难最对的事情之一。这是由于它们的并行性质所致,即要求采用与使用单线程的线性编程完全不同的思维模式。对于这个问题,恰当类比就是抛接杂耍表演者,必须在空中抛接多个球,而不要让它们相互干扰。

最大限度地降低多线程 C# 代码的复杂性
分支或多线程编程是编程时最难最对的事情之一。这是由于它们的并行性质所致,即要求采用与使用单线程的线性编程完全不同的思维模式。对于这个问题,恰当类比就是抛接杂耍表演者,必须在空中抛接多个球,而不要让它们相互干扰。这是一项重大挑战。然而,通过正确的工具和思维模式,这项挑战是能应对的。

本文将深入介绍我为了简化多线程编程和避免争用条件、死锁等其他问题而编写的一些工具。可以说,工具链以语法糖和神奇委托为依据。不过,引用伟大的爵士音乐家 Miles Davis 的话:“在音乐中,没有声音比有声音更重要。” 声音间断就产生了奇迹。

从另一个角度来说,不一定是关乎可以编码什么,而是关乎可以选择不编码什么,因为你希望通过间断代码行产生一点奇迹。引用 Bill Gates 的一句话:“根据代码行数来衡量工作质量就像通过重量来衡量飞机质量一样。” 因此,我希望能帮助开发人员减少编码量,而不是教导开发人员如何编写更多代码。

同步挑战#
在多线程编程方面遇到的第一个问题是,同步对共享资源的访问权限。当两个或多个线程共享对某个对象的访问权限且可能同时尝试修改此对象时,就会出现这个问题。当 C# 首次发布时,lock 语句实现了一种基本方法,可确保只有一个线程能访问指定资源(如数据文件),且效果很好。C# 中的 lock 关键字很容易理解,它独自颠覆了我们对这个问题的思考方式。

不过,简单的 lock 存在一个主要缺陷:它不区分只读访问权限和写入访问权限。例如,可能要从共享对象中读取 10 个不同的线程,并且通过 System.Threading 命名空间中的 ReaderWriterLockSlim 类授权这些线程同时访问实例,而不导致问题发生。与 lock 语句不同,此类可便于指定代码是将内容写入对象,还是只从对象读取内容。这样一来,多个读取器可以同时进入,但在其他所有读写线程均已完成自己的工作前,拒绝任何写入代码访问。

现在的问题是:如果使用 ReaderWriterLock 类,语法就会变得很麻烦,大量的重复代码既降低了可读性,又随时间变化增加了维护复杂性,并且代码中通常会分散有多个 try 和 finally 块。即使是简单的拼写错误,也可能会带来日后有时极难发现的灾难性影响。

通过将 ReaderWriterLockSlim 封装到简单的类中,这个问题瞬间解决,不仅重复代码不再会出现,而且还降低了小拼写错误毁一天劳动成果的风险。图 1 中的类完全基于 lambda 技巧。可以说,这就是对一些委托应用的语法糖(假设存在几个接口)。最重要的是,它在很大程度上有助于实现避免重复代码原则 (DRY)。

图 1:封装 ReaderWriterLockSlim
复制代码
1 public class Synchronizer where TImpl : TIWrite, TIRead {
2 ReaderWriterLockSlim _lock = new ReaderWriterLockSlim ();
3 TImpl _shared;
4
5 public Synchronizer (TImpl shared) {
6 _shared = shared;
7 }
8
9 public void Read (Action functor) {
10 _lock.EnterReadLock ();
11 try {
12 functor (_shared);
13 } finally {
14 _lock.ExitReadLock ();
15 }
16 }
17
18 public void Write (Action functor) {
19 _lock.EnterWriteLock ();
20 try {
21 functor (_shared);
22 } finally {
23 _lock.ExitWriteLock ();
24 }
25 }
26 }
复制代码
图 1 中只有 27 行代码,但却精妙简洁地确保对象跨多个线程进行同步。此类假定类型中有读取接口和写入接口。如果由于某种原因而无法更改需要将访问权限同步到的基础类实现,也可以重复模板类本身三次,通过这种方式使用它。基本用法如图 2 所示。

图 2:使用 Synchronizer 类
复制代码
1 interface IReadFromShared {
2 string GetValue ();
3 }
4
5 interface IWriteToShared {
6 void SetValue (string value);
7 }
8
9 class MySharedClass : IReadFromShared, IWriteToShared {
10 string _foo;
11
12 public string GetValue () {
13 return _foo;
14 }
15
16 public void SetValue (string value) {
17 _foo = value;
18 }
19 }
20
21 void Foo (Synchronizer sync) {
22 sync.Write (x => {
23 x.SetValue ("new value");
24 });
25 sync.Read (x => {
26 Console.WriteLine (x.GetValue ());
27 })
28 }
复制代码
在图 2 的代码中,无论有多少线程在执行 Foo 方法,只要执行另一个 Read 或 Write 方法,就不会调用 Write 方法。不过,可以同时调用多个 Read 方法,而不必在代码中分散多个 try/catch/finally 语句,也不必不断重复相同的代码。我在此郑重声明,通过简单字符串来使用它是没有意义的,因为 System.String 不可变。我使用简单的字符串对象来简化示例。

基本思路是,必须将所有可以修改实例状态的方法都添加到 IWriteToShared 接口中。同时,应将所有只从实例读取内容的方法都添加到 IReadFromShared 接口中。通过将诸如此类的问题分散到两个不同的接口,并对基础类型实现这两个接口,可使用 Synchronizer 类来同步对实例的访问权限。这样一来,将访问权限同步到代码的做法变得更简单,并且基本上可以通过更具声明性的方式这样做。

在多线程编程方面,语法糖可能会决定成败。调试多线程代码通常极为困难,并且创建同步对象的单元测试可能会是徒劳无功之举。

如果需要,可以创建只包含一个泛型参数的重载类型,不仅继承自原始 Synchronizer 类,还将它的一个泛型参数作为类型参数三次传递到它的基类。这样一来,就不需要读取接口或写入接口了,因为可以直接使用类型的具体实现。不过,这种方法要求手动处理需要使用 Write 或 Read 方法的部分。此外,虽然它的安全性稍差一点,但确实可便于将无法更改的类包装到 Synchronizer 实例中。

用于分支的 lambda 集合#
迈出第一步来使用神奇的 lambda(或在 C# 中称为“委托”)后,不难想象,可以利用它们完成更多操作。例如,反复出现的常见多线程主题是,让多个线程与其他服务器联系,以提取数据并将数据返回给调用方。

最简单的例子就是,应用程序从 20 个网页读取数据,并在完成后将 HTML 返回给一个根据所有网页的内容创建某种聚合结果的线程。除非为每个检索方法都创建一个线程,否则此代码的运行速度比预期慢得多:99% 的所有执行时间可能会花在等待 HTTP 请求返回上。

在一个线程上运行此代码的效率很低,并且线程创建语法非常容易出错。随着你支持多个线程及其助理对象,挑战变得更严峻,开发人员不得不在编写代码时使用重复代码。意识到可以创建委托集合和用于包装这些委托的类后,便能使用一个方法调用来创建所有线程。这样一来,创建线程就轻松多了。

图 3 中的一段代码创建两个并行运行的此类 lambda。请注意,此代码实际上来自我的第一版 Lizzie 脚本语言的单元测试 (bit.ly/2FfH5y8)。

图 3:创建 lambda
复制代码
1 public void ExecuteParallel_1 () {
2 var sync = new Synchronizer ("initial_");
3
4 var actions = new Actions ();
5 actions.Add (() => sync.Assign ((res) => res + "foo"));
6 actions.Add (() => sync.Assign ((res) => res + "bar"));
7
8 actions.ExecuteParallel ();
9
10 string result = null;
11 sync.Read (delegate (string val) { result = val; });
12 Assert.AreEqual (true, "initial_foobar" == result || result == "initial_barfoo");
13 }
复制代码
仔细看看这段代码便会发现,计算结果并未假定我的两个 lambda 的执行存先后顺序。执行顺序并未明确指定,并且这些 lambda 是在不同的线程上执行。这是因为,使用图 3 中的 Actions 类,可以向它添加委托,这样稍后就能决定是要并行执行委托,还是按顺序执行委托。

为此,必须使用首选机制创建并执行许多 lambda。在图 3 中可以看到前面提到的 Synchronizer 类,用于同步对共享字符串资源的访问权限。不过,它对 Synchronizer 使用了新方法 Assign,我并未在图 1中的列表内为 Synchronizer 类添加此方法。Assign 方法使用前面 Write 和 Read 方法中使用的相同“lambda 技巧”。

若要研究 Actions 类的实现,请务必下载 Lizzie 版本 0.1,因为我在后面推出的版本中完全重写了代码,使之成为独立编程语言。

C# 中的函数式编程#
大多数开发人员往往认为,C# 几乎与面向对象的编程 (OOP) 同义或至少密切相关,事实显然如此。不过,通过重新思考如何使用 C#,并深入了解它的各方面功能,解决一些问题就变得更加简单了。目前形式的 OOP 不太易于重用,原因很多是因为它是强类型。

例如,如果重用一个类,就不得不重用初始类引用的每个类(在两种情况下,类都是通过组合和继承进行使用)。此外,类重用还会强制重用这些第三方类引用的所有类等。如果这些类是在不同的程序集中实现,必须添加各种各样的程序集,才能获得对一个类型上单个方法的访问权限。

我曾经看过一个可以说明这个问题的类比:“虽然想要的是香蕉,但最终得到的是手拿香蕉的大猩猩,以及大猩猩所居住的热带雨林。” 将这种情况与使用更动态的语言(如 JavaScript)进行重用做比较,后者并不关心类型,只要它实现函数本身使用的函数即可。通过略微宽松类型方法生成的代码更灵活、更易于重用。委托可以实现这一点。

可使用 C# 来改善跨多个项目重用代码的过程。只需要理解函数或委托也可以是对象,并且可以通过弱类型方式控制这些对象的集合。

早在 2018 年 11 月发行的《MSDN 杂志》中,我发表过一篇标题为“使用符号委托创建你自己的脚本语言”的文章 (msdn.com/magazine/mt830373)。本文中提到的有关委托的思路是在这篇文章的基础之上形成。本文还介绍了 Lizzie,这是我的自制脚本语言,它的存在归功于这种以委托为中心的思维模式。如果我使用 OOP 规则创建了 Lizzie,我会认为,它在大小上可能至少大一个数量级。

当然,如今 OOP 和强类型处于主导地位,想要找到一个主要必需技能不要求它的职位描述,几乎是不可能的。我在此郑重声明,我创建 OOP 代码的时间已超过 25 年,所以,我与任何人一样都会因为对强类型有偏见而感到内疚。然而,如今我在编码方法上更加务实,对类层次结构的最终外观失去兴趣。

并不是我不欣赏外观精美的类层次结构,而是收益递减。添加到层次结构中的类越多,它就变得越臃肿,直到因不堪重压而崩溃。有时,卓越的设计只用很少的方法、更少的类和大多数松散耦合的函数,这样就可以轻松扩展代码,也就不需要“引入大猩猩和热带雨林”了。

回到本文反复出现的主题(从 Miles Davis 的音乐方法中获得灵感):少即是多(“没有声音比有声音更重要”)。 代码也不例外。间断代码行往往会产生奇迹,最佳解决方案的衡量依据更多是不编码什么,而不是编码什么。连傻瓜也可以将喇叭吹响,但只有为数不多的人才能用喇叭吹奏出音乐。像 Miles 这样能创造出奇迹的人就更少了。

原文作者:Thomas Hansen

原文地址:Minimize Complexity in Multithreaded C# Code

相关文章
|
27天前
|
缓存 C# Windows
C#程序如何编译成Native代码
【10月更文挑战第15天】在C#中,可以通过.NET Native和第三方工具(如Ngen.exe)将程序编译成Native代码,以提升性能和启动速度。.NET Native适用于UWP应用,而Ngen.exe则通过预编译托管程序集为本地机器代码来加速启动。不过,这些方法也可能增加编译时间和部署复杂度。
|
4月前
|
安全 Python
告别低效编程!Python线程与进程并发技术详解,让你的代码飞起来!
【7月更文挑战第9天】Python并发编程提升效率:**理解并发与并行,线程借助`threading`模块处理IO密集型任务,受限于GIL;进程用`multiprocessing`实现并行,绕过GIL限制。示例展示线程和进程创建及同步。选择合适模型,注意线程安全,利用多核,优化性能,实现高效并发编程。
69 3
|
3月前
|
C# 开发者 Windows
在VB.NET项目中使用C#编写的代码
在VB.NET项目中使用C#编写的代码
53 0
|
1月前
|
C#
C# 图形验证码实现登录校验代码
C# 图形验证码实现登录校验代码
72 2
|
1月前
|
中间件 数据库连接 API
C#数据分表核心代码
C#数据分表核心代码
33 0
|
3月前
|
Java Windows
【Azure Developer】Windows中通过pslist命令查看到Java进程和线程信息,但为什么和代码中打印出来的进程号不一致呢?
【Azure Developer】Windows中通过pslist命令查看到Java进程和线程信息,但为什么和代码中打印出来的进程号不一致呢?
|
3月前
|
物联网 C# Windows
看看如何使用 C# 代码让 MQTT 进行完美通信
看看如何使用 C# 代码让 MQTT 进行完美通信
533 0
|
3月前
|
数据安全/隐私保护 C# UED
利用 Xamarin 开展企业级移动应用开发:从用户登录到客户管理,全面演示C#与Xamarin.Forms构建跨平台CRM应用的实战技巧与代码示例
【8月更文挑战第31天】利用 Xamarin 进行企业级移动应用开发能显著提升效率并确保高质量和高性能。Xamarin 的跨平台特性使得开发者可以通过单一的 C# 代码库构建 iOS、Android 和 Windows 应用,帮助企业快速推出产品并保持一致的用户体验。本文通过一个简单的 CRM 示例应用演示 Xamarin 的使用方法,并提供了具体的代码示例。该应用包括用户登录、客户列表显示和添加新客户等功能。此外,还介绍了如何增强应用的安全性、数据持久化、性能优化及可扩展性,从而构建出功能全面且体验良好的移动应用。
50 0
|
3月前
|
前端开发 开发者 Apache
揭秘Apache Wicket项目结构:如何打造Web应用的钢铁长城,告别混乱代码!
【8月更文挑战第31天】Apache Wicket凭借其组件化设计深受Java Web开发者青睐。本文详细解析了Wicket项目结构,帮助你构建可维护的大型Web应用。通过示例展示了如何使用Maven管理依赖,并组织页面、组件及业务逻辑,确保代码清晰易懂。Wicket提供的页面继承、组件重用等功能进一步增强了项目的可维护性和扩展性。掌握这些技巧,能够显著提升开发效率,构建更稳定的Web应用。
98 0
|
3月前
|
前端开发 程序员 API
从后端到前端的无缝切换:一名C#程序员如何借助Blazor技术实现全栈开发的梦想——深入解析Blazor框架下的Web应用构建之旅,附带实战代码示例与项目配置技巧揭露
【8月更文挑战第31天】本文通过详细步骤和代码示例,介绍了如何利用 Blazor 构建全栈 Web 应用。从创建新的 Blazor WebAssembly 项目开始,逐步演示了前后端分离的服务架构设计,包括 REST API 的设置及 Blazor 组件的数据展示。通过整合前后端逻辑,C# 开发者能够在统一环境中实现高效且一致的全栈开发。Blazor 的引入不仅简化了 Web 应用开发流程,还为习惯于后端开发的程序员提供了进入前端世界的桥梁。
273 0