您的位置  > 互联网

2019年2月第34卷:多线程编程的奇迹

2019年2月

第34卷第2期

[C#] 最小化多线程C#代码的复杂度

作者 | 2019年2月

分支或多线程编程是编程时最难做好的事情之一。 这是由于它们的并行性质,这需要与使用单线程的线性编程完全不同的思维方式。 这个问题的一个很好的类比是,一个杂耍者必须在空中杂耍多个球,而这些球不能互相干扰。 这是一个重大挑战。 然而,只要有正确的工具和心态,就可以应对这一挑战。

本文将深入探讨我编写的一些工具,用于简化多线程编程并避免竞争条件、死锁和其他问题。 可以说,工具链是基于语法糖和魔法委托的。 然而,引用伟大的爵士音乐家迈尔斯·戴维斯的话:“在音乐中,声音的缺席比声音的存在更重要。” 声音打破创造奇迹。

从另一个角度来看,这不一定是关于你可以编码什么,而是关于你可以选择不编码什么,因为你希望通过破坏代码行可以发生一点点魔法。 引用比尔·盖茨的话:“通过代码行数来衡量工作质量就像通过重量来衡量飞机的质量一样。” 因此,我希望帮助开发者少写代码,而不是教开发者如何写更多的代码。

同步挑战

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

然而,简单的锁有一个主要缺陷:它不区分只读访问和写访问。 例如,您可能有 10 个不同的线程从共享对象读取数据,并通过 . 您可以授权这些线程同时访问实例,而不会引起问题。 与 lock 语句不同,此类可以轻松指定代码是将内容写入对象还是仅从对象读取内容。 这允许多个读取器同时进入,但拒绝任何写入代码访问,直到所有其他读取和写入线程完成其工作。

现在的问题是:如果使用类,语法就会变得繁琐,大量的重复代码不仅降低了可读性,而且随着时间的推移,维护复杂度也会增加,而且代码通常分散在多个try和块中。 。 即使是简单的拼写错误也可能产生灾难性的影响,有时很难在以后发现。

通过将其封装成一个简单的类,这个问题就立刻解决了。 不仅不再有重复的代码,而且还降低了因小拼写错误而毁掉一天工作的风险。 图 1**** 中的类完全基于技术。 可以说,这是应用于某些委托的语法糖(假设有多个接口)。 最重要的是,它在很大程度上有助于实现避免重复代码(DRY)原则。

图 1:封装

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

图 1 中只有 27 行代码,但它是确保对象在多个线程之间同步的一种优雅而简洁的方法。 该类假设类型中有读取和写入接口。 如果由于某种原因您无法更改需要同步访问的基类实现,您也可以通过重复模板类本身 3 次来使用它。 基本用法如图2****所示。

图 2:使用类

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 sync)
{
  sync.Write(x => {
    x.SetValue("new value");
  });
  sync.Read(x => {
    Console.WriteLine(x.GetValue());
  })
}

在图2的代码中,无论有多少个线程正在执行Foo方法,只要执行另一个Read或Write方法,就不会调用Write方法。 但是,您可以同时调用多个 Read 方法,而不必在代码中分散多个 try/catch/ 语句或不断重复相同的代码。 我在此声明,通过简单的字符串使用它是没有意义的,因为 . 是不可变的。 我使用简单的字符串对象来简化示例。

基本思想是所有可以修改实例状态的方法都必须添加到接口中。 同时,所有只从实例读取内容的方法都应该添加到接口中。 通过将此类问题分散到两个不同的接口并在底层类型上实现这两个接口,可以使用类来同步对实例的访问。 这使得同步代码的访问权限变得更加容易,并且本质上允许您以更具声明性的方式执行此操作。

在多线程编程方面,语法糖可能是成功与失败的区别。 调试多线程代码通常非常困难,并且为同步对象创建单元测试可能是徒劳的。

如果需要,您可以创建一个仅包含一个泛型参数的重载类型,不仅从原始类继承,而且将其泛型参数之一作为类型参数三次传递给其基类。 这样,就不需要读取接口或写入接口,因为可以直接使用类型的具体实现。 然而,这种方法需要手动处理需要使用写入或读取方法的部分。 此外,虽然它的安全性稍差,但它确实使将不可更改的类包装到实例中变得更容易。

分支集合

一旦您迈出了使用魔法(或在 C# 中称为“委托”)的第一步,就很容易想象您可以用它们做多少事情。 例如,反复出现的常见多线程主题是让多个线程联系其他服务器以提取数据并将其返回给调用者。

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

在线程上运行此代码效率低下,并且线程创建语法非常容易出错。 当您支持多个线程及其辅助对象时,挑战变得更加严峻,迫使开发人员在编写代码时重复代码。 一旦您意识到可以创建委托和包装这些委托的类的集合,您就可以使用单个方法调用来创建所有线程。 这样,创建线程就容易多了。

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

图 3:创建

public void ExecuteParallel_1()
{
  var sync = new Synchronizer("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");
}

如果你仔细看这段代码,你会发现计算结果并没有假设我两个的执行顺序。 执行顺序没有明确指定,并且这些是在不同的线程上执行的。 这是因为,使用图 3 中的类,您可以向其中添加委托,然后决定是并行还是顺序执行委托。

为此,必须使用首选机制创建和执行许多操作。 在图 3**** 中,您可以看到前面提到的用于同步对共享字符串资源的访问的类。 但是,它使用了一个新方法,我没有将其添加到图 1 列表中的类中。该方法使用了之前在 Write 和 Read 方法中使用的相同“技巧”。

要探索该类的实现,请务必下载 0.1 版本,因为我在后续版本中完全重写了代码,使其成为独立的编程语言。

C# 中的函数式编程

大多数开发人员倾向于认为 C# 几乎是同义词,或者至少与面向对象编程 (OOP) 密切相关,事实确实如此。 然而,通过重新思考如何使用 C# 并更深入地了解其功能,解决一些问题会变得更容易。 当前形式的 OOP 不容易重用,在许多情况下,因为它是强类型的。

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

我曾经看到一个比喻很能说明这个问题:“你想要的是一根香蕉,但你最终得到的是一只拿着香蕉的大猩猩,以及大猩猩居住的雨林。” 将这种情况与使用更加动态的重用性语言(例如 )进行比较,它不关心类型,只要实现函数本身即可。 通过稍微宽松的类型方法生成的代码更加灵活并且更易于重用。 委派可以实现这一点。

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

早在 2018 年 11 月号的 MSDN 杂志上,我发表了一篇题为“使用符号委托创建您自己的脚本语言”(//) 的文章。 本文提到的关于委托的想法就是基于这篇文章。 本文还介绍了我自制的脚本语言,它的存在归功于这种以委托为中心的思维方式。 如果我使用 OOP 规则创建它,我想它的大小可能至少大一个数量级。

当然,随着当今面向对象编程和强类型的主导,几乎不可能找到不要求它作为主要必需技能的工作描述。 郑重声明,我创建 OOP 代码已经超过 25 年了,所以我和任何人一样对强类型存在偏见感到内疚。 然而,现在我的编码方法更加务实,并且对类层次结构的最终外观失去了兴趣。

并不是我不欣赏好看的类层次结构,而是收益递减。 添加到层次结构中的类越多,它就会变得越臃肿,直到它在重量下崩溃。 有时,一个伟大的设计具有更少的方法、更少的类,并且大多是松散耦合的函数,这样代码就可以轻松扩展,而不需要“引入大猩猩和雨林”。

回到本文反复出现的主题(从迈尔斯·戴维斯的音乐方法中汲取灵感):少即是多(“没有声音比声音更重要”)。 代码也不例外。 断行的代码通常会产生奇迹,最好的解决方案更多的是通过未编码的内容而不是编码的内容来衡量。 即使是傻瓜也会吹喇叭,但只有少数人能用喇叭演奏音乐。 像迈尔斯这样能够创造奇迹的人就更少了。

**** 是一名金融科技和外汇行业的软件开发人员,目前居住在塞浦路斯。

在 MSDN 杂志论坛中讨论本文