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

本文将深入介绍我为了简化多线程编程和避免争用条件、死锁等其他问题而编写的一些工具。可以说,工具链以语法糖和神奇委托为依据。不过,引用伟大的爵士音乐家 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. Vue学习小结(一)安装依赖与数据来源

    不多说啥了,生活中都是各种阵痛与惊喜.最近在学习vue框架,刚写完一个小型的后台管理系统(https://github.com/michaelzhengzm/info-manager-systerm_ ...

  2. CSS 圣杯布局 / 双飞翼布局的实现

    工作的越久,有些基础知识我们可能就逐渐淡忘了,今天我们来回顾一下css的圣杯布局和双飞翼布局, 这两个名词你可能不熟, 那三栏布局你肯定就非常熟悉了, 就是两边定宽, 中间自适应 的 布局 1 , 圣 ...

  3. Python爬虫实践 -- 记录我的第二只爬虫

    1.爬虫基本原理 我们爬取中国电影最受欢迎的影片<红海行动>的相关信息.其实,爬虫获取网页信息和人工获取信息,原理基本是一致的. 人工操作步骤: 1. 获取电影信息的页面 2. 定位(找到 ...

  4. 跳动在网页中间的精灵----Javascript

    今天开始js的内容整理,跳动在网页里的精灵就是它了. 一.简介 1.什么是Javascript JavaScript 是一种具有面向对象能力的.解释型的程序设计语言.更具体一点,它是基于对象和事件驱动 ...

  5. Java核心技术梳理-集合

    一.前言 在日常开发中,我们经常会碰到需要在运行时才知道对象个数的情况,这种情况不能使用数组,因为数组是固定数量的,这个时候我们就会使用集合,因为集合可以存储数量不确定的对象. 集合类是特别有用的工具 ...

  6. 想在京津冀地区找个UI&UE的工作靠啥?看看这篇博客

    文章目的 本篇文章针对京津冀计算机专业应届毕业生,求职UI相关行业,提供数据参考. 本篇文章试用对象 UI相关培训机构 计算机设计相关培训机构 高职专科类学院计算机相关专业 就业参考网站 智联招聘 拉 ...

  7. Python的垃圾回收机制(引用计数+标记清除+分代回收)

    一.写在前面: 我们都知道Python一种面向对象的脚本语言,对象是Python中非常重要的一个概念.在Python中数字是对象,字符串是对象,任何事物都是对象,而它们的核心就是一个结构体--PyOb ...

  8. 结合JDK源码看设计模式——装饰者模式

    定义 在不改变原有对象的基础之上,将功能附加到对象上 适用场景 扩展一个类的功能 动态的给对象增加功能,当功能不需要的时候能够动态删除 详解 在看到定义的时候,可能很多人会想,这不就是继承吗?的确很像 ...

  9. SQL Server2008进程堵塞处理方法

    进程堵塞处理方法: select * from sys.sysprocesses where blocked <>0 and DB_NAME(dbid)='GSHCPDB'   ##查询堵 ...

  10. python3 完全理解赋值,浅copy,深copy 通过地址详细理解~

    额...老规矩,先来一天NLP再说,也没几条了. 十,在任何一个系统里,最灵活的部分是最能影响大局的部分 灵活便是有一个以上的选择,选择便是能力,因此最灵活的人便是最有能力的人. 灵活来自减少只相信自 ...