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

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

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

同步挑战

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

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

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

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

图 1:封装 ReaderWriterLockSlim
 public class Synchronizer<TImpl, TIRead, TIWrite> where TImpl : TIWrite, TIRead {
ReaderWriterLockSlim _lock = new ReaderWriterLockSlim ();
TImpl _shared; public Synchronizer (TImpl shared) {
_shared = shared;
} public void Read (Action<TIRead> functor) {
_lock.EnterReadLock ();
try {
functor (_shared);
} finally {
_lock.ExitReadLock ();
}
} public void Write (Action<TIWrite> functor) {
_lock.EnterWriteLock ();
try {
functor (_shared);
} finally {
_lock.ExitWriteLock ();
}
}
}

图 1 中只有 27 行代码,但却精妙简洁地确保对象跨多个线程进行同步。此类假定类型中有读取接口和写入接口。如果由于某种原因而无法更改需要将访问权限同步到的基础类实现,也可以重复模板类本身三次,通过这种方式使用它。基本用法如图 2 所示。

图 2:使用 Synchronizer 类
 interface IReadFromShared {
string GetValue ();
} interface IWriteToShared {
void SetValue (string value);
} class MySharedClass : IReadFromShared, IWriteToShared {
string _foo; public string GetValue () {
return _foo;
} public void SetValue (string value) {
_foo = value;
}
} void Foo (Synchronizer<MySharedClass, IReadFromShared, IWriteToShared> sync) {
sync.Write (x => {
x.SetValue ("new value");
});
sync.Read (x => {
Console.WriteLine (x.GetValue ());
})
}

在图 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
 public void ExecuteParallel_1 () {
var sync = new Synchronizer<string, string, string> ("initial_"); var actions = new Actions ();
actions.Add (() => sync.Assign ((res) => res + "foo"));
actions.Add (() => sync.Assign ((res) => res + "bar")); actions.ExecuteParallel (); string result = null;
sync.Read (delegate (string val) { result = val; });
Assert.AreEqual (true, "initial_foobar" == result || result == "initial_barfoo");
}

仔细看看这段代码便会发现,计算结果并未假定我的两个 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

【译】最大限度地降低多线程 C# 代码的复杂性的更多相关文章

  1. java 22 - 9 多线程之 代码实现的方式2

    多线程的代码实现: 方式2:实现Runnable接口 步骤: A:自定义类MyRunnable实现Runnable接口 B:重写run()方法 C:创建MyRunnable类的对象 D:创建Threa ...

  2. 译 PrestaShop开发者指南 第二篇 代码规范

    原文:<http://doc.prestashop.com/display/PS15/Coding+Standards> 废话不多译了,讲重点. 代码风格验证工具:CodeSniffer( ...

  3. JAVA之旅(十三)——线程的安全性,synchronized关键字,多线程同步代码块,同步函数,同步函数的锁是this

    JAVA之旅(十三)--线程的安全性,synchronized关键字,多线程同步代码块,同步函数,同步函数的锁是this 我们继续上个篇幅接着讲线程的知识点 一.线程的安全性 当我们开启四个窗口(线程 ...

  4. 在IntelliJ IDEA中多线程并发代码的调试方法

    通常来说,多线程的并发及条件断点的debug是很难完成的,或许本篇文章会给你提供一个友好的调试方法.让你在多线程开发过程中的调试更加的有的放矢. 我们将通过一个例子来学习.在这里,我编写了一个多线程程 ...

  5. 0037 Java学习笔记-多线程-同步代码块、同步方法、同步锁

    什么是同步 在上一篇0036 Java学习笔记-多线程-创建线程的三种方式示例代码中,实现Runnable创建多条线程,输出中的结果中会有错误,比如一张票卖了两次,有的票没卖的情况,因为线程对象被多条 ...

  6. java 22 - 21 多线程之多线程的代码实现方式3

    JDK5新增了一个Executors工厂类来产生线程池,有如下几个方法 A.public static ExecutorService newCachedThreadPool() B.public s ...

  7. java 22 - 4 多线程的代码实现的方式1

    需求:我们要实现多线程的程序. 如何实现呢? 由于线程是依赖进程而存在的,所以我们应该先创建一个进程出来. 而进程是由系统创建的,所以我们应该去调用系统功能创建一个进程. Java是不能直接调用系统功 ...

  8. Python3 多线程下载代码

    根据http://www.oschina.net/code/snippet_70229_2407修改而来的增强版.貌似原版源自Axel这个多线程下载工具. ''' Created on 2014-10 ...

  9. C#多线程管理代码

    /// <summary> /// 多线程执行 /// </summary> public class MultiThreadingWorker { /// <summa ...

随机推荐

  1. 前端性能优化 —— 添加Expires头与Cache-control区别

    要:添加Expires头能有效的利用浏览器的缓存能力来改善页面的性能,能在后续的页面中有效避免很多不必要的Http请求,WEB服务器使用Expires头来告诉Web客户端它可以使用一个组件的当前副本, ...

  2. Spark2.4.0伪分布式环境搭建

    一.搭建环境的前提条件 环境:ubuntu-16.04 hadoop-2.6.0  jdk1.8.0_161. spark-2.4.0-bin-hadoop2.6.这里的环境不一定需要和我一样,基本版 ...

  3. shiro的SecurityUtis

    接着上一篇来继续分析shiro源码 这篇主要讲解shiro里面的SecurityUtils 首先我们看该类供我们在业务中用的仅有两个get方法,那么这两个get方法获取的subject和sercuri ...

  4. 初探奥尔良(Orleans)

    由于工作上关系目前经常被各种并发数据问题搞得焦头烂额,要么要性能舍弃数据上得一致性,要么要一致性但是却得到了特别糟糕的响应.难道鱼和熊掌真的无法兼得吗? 然后找到了类似奥尔良这种基于Actor模型的k ...

  5. DSAPI 生成桌面图标(带数字)

    功能:在桌面上创建一个带有指定数字的图标. 效果图: 生成的ICO图标 代码 Private Sub 生成桌面图标(消息数量 As Integer) Try Dim B As New Bitmap(M ...

  6. php 通过header下载中文文件名 压缩包损坏或文件不存在的问题

    开发中大家都是使用的utf8编码,昨天遇到一个奇坑,本是一件很小的问题,解决也浪费了个吧小时.废话不多说,植入正题: 文件下载方式:通过header二进制流文件下载需求: 文件上传保留文件名不变数据字 ...

  7. 学习python的第一天

    2019.4.25自我总结 一.Typora 关于用Typora 自我感觉良好,基本快捷键也比较简单,ps:还是要多用用 二.编程 1.编程语言 是用来定义计算机程序的形式语言.它是一种被标准化的交流 ...

  8. 立即执行函数 IIFE

    立即执行函数表达式IIFE(Immediately-invoked function expression)我们知道,在javascript(ES5)中,是没有块级作用域的概念的.看一个例子 for ...

  9. RxJava操作符的简单使用

    一.准备工作在app的build.gradle文件下的dependencies中添加依赖: compile 'io.reactivex:rxjava:1.3.0' compile 'io.reacti ...

  10. Linux stat函数和stat命令

    stat函数和stat命令 linux文件里的[inode = index node]解释:要理解inode必须了解磁盘和[目录项],inode实际是连接[目录项]和磁盘的中间物质. 图里的大圈代表硬 ...