作者都是各自领域经过审查的专家,并撰写他们有经验的主题. 我们所有的内容都经过同行评审,并由同一领域的Toptal专家验证.
Alex Lynch
Verified Expert in Engineering
11 Years of Experience

Alex是一名iOS和全栈开发专家,拥有超过11年的iOS经验和20年的应用程序开发经验.

Expertise

Share

中的并发算法的设计、测试和维护 Swift 正确设置细节是否对你的应用的成功至关重要. 并发算法(也称为并行编程)是一种算法,旨在同时执行多个(可能是许多)操作,以利用更多的硬件资源并减少总体执行时间.

在苹果的平台上,编写并发算法的传统方法是 NSOperation. NSOperation的设计要求程序员将并发算法细分为单独的长期运行算法, asynchronous tasks. 每个任务都将在NSOperation的子类中定义,这些类的实例将通过一个目标API组合在一起,以在运行时创建任务的部分顺序. 这种设计并行算法的方法是苹果平台上最先进的技术,持续了7年.

In 2014 Apple introduced Grand Central Dispatch (GCD)作为并发操作表达的一个戏剧性的进步. GCD, 随着新的语言功能块伴随和支持它, 提供了一种在初始化异步请求后立即简洁地描述异步响应处理程序的方法. 程序员不再被鼓励在多个NSOperation子类中的多个文件中扩展并发任务的定义. 现在,可以在单个方法中编写整个并发算法. 表达性和类型安全性的增加是一个重要的概念转变. 这种写法的典型算法可能如下所示:

函数processessimagedata(完成):(结果:图像?, error: Error?) -> Void) {
  loadWebResource("dataprofile.txt") { (dataResource, error) in
    guard让dataResource = dataResource else {
      completion(nil, error)
      return
    }
    loadWebResource("imagedata.{(imageResource,错误)in
      guard let imageResource = imageResource else {
        completion(nil, error)
        return
      }
      decodeImage(dataResource, imageResource) {(imageTmp, error) in
        guard let imageTmp = imageTmp else {
          completion(nil, error)
          return
        }
        dewarpAndCleanupImage(imageTmp) {imageResult in
          guard let imageResult = imageResult else {
            completion(nil, error)
            return
          }
          completion(imageResult, nil)
        }
      }
    }
  }
}

让我们稍微分解一下这个算法. The function processImageData 是一个异步函数,它自己进行四个异步调用来完成它的工作吗. 这四个异步调用一个嵌套在另一个内部,这是基于块的异步处理最自然的方式. 每个结果块都有一个可选的Error参数,除了一个之外,其他所有结果块都包含一个额外的可选参数,表示aysnc操作的结果.

上面代码块的形状对大多数Swift开发人员来说可能很熟悉. 但是这种方法有什么问题呢? 下面列出的痛点可能同样熟悉.

  • 这种嵌套代码块的“厄运金字塔”形状很快就会变得笨拙. 如果我们再添加两个异步操作会发生什么呢? Four? 条件运算呢?? 重试行为或资源限制保护呢? 现实世界的代码永远不会像博客文章中的示例那样干净简单. “厄运金字塔”效应很容易导致代码难以阅读, hard to maintain, and prone to bugs.
  • 在上面的例子中,尽管使用了Swifty,但是错误处理的尝试实际上是不完整的. 程序员已经假设了两个参数, Objective-C style async callback blocks will always provide one of the two parameters; they will never both be nil at the same time. This is not a safe assumption. 并发算法以难以编写和调试而闻名, 毫无根据的假设是部分原因. 对于任何打算在现实世界中运行的并发算法来说,完整和正确的错误处理是不可避免的必要条件.
  • Taking this thought even further, 也许编写被调用的异步函数的程序员不像您那样有原则. 如果在某些情况下被调用的函数无法回调怎么办? Or call back more than once? 在这种情况下,processImageData的正确性会发生什么变化? Pros don’t take chances. 关键任务函数需要正确,即使它们依赖于第三方编写的函数.
  • 也许最引人注目的是,所考虑的异步算法是次优构造的. 前两个异步操作都是下载远程资源. 尽管它们没有相互依赖关系,但上述算法按顺序执行下载,而不是并行执行. The reasons for this are obvious; the nested block syntax encourages such wastefulness. 竞争激烈的市场不会容忍不必要的滞后. 如果你的应用不能尽快执行异步操作,其他应用会的.

How can we do better? HoneyBee 是一个未来/承诺库,使Swift并发编程变得简单、富有表现力和安全. 让我们用蜜蜂重写上面的异步算法并检查结果:

函数processessimagedata(完成):(结果:图像?, error: Error?) -> Void) {
  HoneyBee.start()
    .setErrorHandler {completion(nil, $0)}
    .branch { stem in
      stem.chain(loadWebResource =<< "dataprofile.txt")
       +
       stem.chain(loadWebResource =<< "imagedata.dat")
    }
    .chain(decodeImage)
    .chain(dewarpAndCleanupImage)
    .chain { completion($0, nil) }
}

这个实现开始的第一行是一个新的蜜蜂配方. 第二行建立默认的错误处理程序. 错误处理在蜜蜂食谱中是不可选的. 如果出现问题,算法必须处理它. 第三行打开一个允许并行执行的分支. The two chains of loadWebResource 将并行执行,它们的结果将合并(第5行). 两个加载资源的组合值被转发给 decodeImage 以此类推,直到completion被调用.

让我们浏览一下上面的痛点列表,看看bee是如何改进这段代码的. 现在维护这个函数要容易得多. 蜜蜂配方看起来就像它所表达的算法. 代码是可读的、可理解的,并且可以快速修改. bee的设计确保任何指令的错误排序都会导致编译时错误, not a runtime error. 该函数现在不太容易受到bug和人为错误的影响.

所有可能的运行时错误已被完全处理. 蜜蜂支持的每个函数签名(总共有38个)都被保证完全处理. In our example, Objective-C风格的双参数回调将产生一个非nil错误,该错误将被路由到错误处理程序, 或者它会产生一个非nil的值,这个值会沿着链向下移动, 否则,如果两个值都为nil, bee将生成一个错误,解释回调函数没有履行其契约.

bee还处理函数回调调用次数的契约正确性. 如果一个函数调用回调失败,bee会产生一个描述性失败. 如果函数多次调用它的回调函数, bee将抑制辅助调用和日志警告. 这两种错误响应(以及其他)都可以根据程序员的个人需求进行定制.

希望大家已经很清楚了 processImageData 适当地并行资源下载以提供最佳性能. 蜜蜂最强大的设计目标之一是,配方应该看起来像它所表达的算法.

Much better. Right? 但“蜜蜂”提供的更多.

请注意:下一个案例研究不适合胆小的人. 考虑以下问题描述:你的手机应用使用 CoreData to persist its state. You have an NSManagedObject 称为Media的模型,它表示上传到后端服务器的媒体资产. 允许用户一次选择数十个媒体项目,并将它们批量上传到后端系统. 媒体首先通过引用String表示,该引用String必须转换为media对象. 幸运的是,你的应用已经包含了一个助手方法来做这件事:

# # # # # # # # # # # # # # # # # #?, Error?) -> Void) {
  // transcoding stuff
  completion(Media(context: managedObjectContext), nil)
}

将媒体引用转换为media对象之后, 必须将媒体项上传到后端. 同样,您已经准备好了一个辅助函数来处理网络事务.

函数上传(_媒体:媒体,完成:@ escape(错误)?) -> Void) {
  // network stuff
  completion(nil)
}

因为用户可以一次选择几十个媒体项目, 用户体验设计师已经为上传进度指定了相当多的反馈. 这些需求被提炼为以下四个功能:

///上传出错时调用
函数errorHandler(_ error: error) {
  // do the right thing
}

///每次mediaRef调用一次,无论上传成功还是失败
函数singleUploadCompletion(_ mediaRef: String) {
  // update a progress indicator
}

///每次上传成功调用一次
函数singleUploadSuccess(_媒体:媒体){
  // do celebratory things
}
///当整个批处理被认为上传成功时调用. 
func totalProcessSuccess() {
  // declare victory
}

However, 因为你的应用程序来源的媒体引用有时是过期的, 业务经理决定,如果至少有一半的上传成功,就向用户发送“成功”消息. 也就是说,并发进程应该宣布胜利并调用 totalProcessSuccess-如果上传失败的次数少于一半. 这是交给作为开发人员的您的规范. 但是作为一个有经验的程序员,您会意识到有更多的需求需要满足.

Of course, 企业希望批量上传尽可能快地发生, 所以串行上传是不可能的. 上传必须并行执行.

But not too much. If you just indiscriminately async the entire batch, 数十个并发上传将使移动网卡(网络接口卡)拥挤不堪。, 实际上,上传的速度比串行的要慢, not faster.

移动网络连接被认为不稳定. 即使是短事务也可能仅仅由于网络连接的变化而失败. 为了真正声明上传失败,我们需要至少重试一次上传.

重试策略不应包括导出操作,因为它不受瞬态故障的影响.

导出过程是计算绑定的,因此必须在主线程之外执行.

因为导出是计算绑定的, 它应该具有比其他上传过程更少的并发实例数量,以避免使处理器崩溃.

上面描述的四个回调函数都更新UI, 所以必须都在主线程上调用.

Media is an NSManagedObject, which comes from an NSManagedObjectContext 并且有自己的螺纹要求,必须遵守.

这个问题说明是不是有点晦涩? 如果你发现类似的问题潜伏在你的未来,不要感到惊讶. 我在自己的工作中遇到过这样的人. 让我们首先尝试用传统工具来解决这个问题. 系好安全带,这可不妙.

///描述算法可能遇到的特定问题的enum. 
enum UploadingError : Error {
  case invalidResponse
  case tooManyFailures
}

///一个信号量,以防止溢出网卡
let outerLimit = DispatchSemaphore(值:4)
///一个信号量,用来防止处理器抖动
让exportLimit = DispatchSemaphore(值:1)
///上传失败后重试的次数
let uploadRetries = 1
///调度组跟踪整个进程何时完成
let fullProcessDispatchGroup = DispatchGroup()
///有多少上传完全完成. 
var uploadSuccesses = 0

//当整个进程完成时调用这个通知块.
fullProcessDispatchGroup.notify(queue: DispatchQueue.main) {
  let successRate = Float(uploadsuccess) / Float(medireferences).count)
  if successRate > 0.5 {
    totalProcessSuccess()
  } else {
    errorHandler(UploadingError.tooManyFailures)
  }
}

// start in the background
DispatchQueue.global().async {
  对于mediaRef在mediarereferences {
    //通知组我们正在启动一个进程
    fullProcessDispatchGroup.enter()
    //等待,直到安全开始上传
    outerLimit.wait()
    
    ///以后需要的普通清理操作
    func finalizeMediaRef() {
      singleUploadCompletion(mediaRef)
      fullProcessDispatchGroup.leave()
      outerLimit.signal()
    }
    
    //等待,直到安全开始导出
    exportLimit.wait()
    导出(mediaRef){(媒体,错误)在
      // allow another export to begin
      exportLimit.signal() 
      if let error = error {
        DispatchQueue.main.async {
          errorHandler(error)
          finalizeMediaRef()
        }
      } else {
        guard let media = media else {
          DispatchQueue.main.async {
            errorHandler(UploadingError.invalidResponse)
            finalizeMediaRef()
          }
          return
        }
        // the export was successful
        
        var uploadAttempts = 0
        ///定义上传过程及其重试行为
        func doUpload() {
          //尊重Media的线程要求
          managedObjectContext.perform {
            upload(media) { error in
              if let error = error {
                if uploadAttempts < uploadRetries {
                  uploadAttempts += 1
                  doUpload() // retry
                } else {
                  DispatchQueue.main.async {
                    // too many upload failures
                    errorHandler(error)
                    finalizeMediaRef()
                  }
                }
              } else {
                DispatchQueue.main.async {
                  uploadSuccesses += 1
                  singleUploadSuccess(media)
                  finalizeMediaRef()
                }
              }
            }
          }
        }
        // kick off the first upload
        doUpload()
      }
    }
  }
}

Woah! 不加注释的话,大概有75行. 你从头到尾都遵循推理了吗? 如果你在新工作的第一周遇到这个怪物,你会作何感想? 您准备好维护或修改它了吗? 你知道它是否包含错误吗? Does it contain errors?

现在,考虑一下蜜蜂的替代方案:

HoneyBee.start(on: DispatchQueue.main)
  .setErrorHandler(errorHandler)
  .insert(mediaReferences)
  .setBlockPerformer(DispatchQueue.global())
  .每个限制:4个,可接受 .ratio(0.5)) { elem in
    elem.finally { link in
      link.setBlockPerformer(DispatchQueue.main)
        .chain(singleUploadCompletion)
    }
    .limit(1) { link in
      link.chain(export)
    }
    .setBlockPerformer(获取)
    .retry(1) { link in
      link.链(上传)//受到暂时失败
    }
    .setBlockPerformer(DispatchQueue.main)
    .chain(singleUploadSuccess)
  }
  .setBlockPerformer(DispatchQueue.main)
  .drop()
  .chain(totalProcessSuccess)

How does this form strike you? 让我们一点一点地解决它. 在第一行,我们从主线程开始,开始编写蜜蜂配方. 通过从主线程开始,我们确保所有错误都将传递给主线程的errorHandler(第2行). Line 3 inserts the mediaReferences array into the process chain. 接下来,我们切换到全局后台队列,为一些并行性做准备. 在第5行,我们开始对每个 mediaReferences. 我们将这种并行性限制为最多4个并发操作. 我们还声明,如果至少有一半的子链成功(不要出错),则认为整个迭代成功。. Line 6 declares a finally 无论下面的子链成功还是失败都将调用的链接. On the finally 链接,我们切换到主线程(第7行)并调用 singleUploadCompletion (line 8). On line 10, 我们将导出操作的最大并行化设置为1(单次执行)(第11行)。. 第13行切换到私有队列 managedObjectContext instance. 第14行声明上载操作的一次重试尝试(第15行). 第17行再次切换到主线程,第18行调用 singleUploadSuccess. 到第20行执行时,所有并行迭代都完成了. 如果少于一半的迭代失败, 然后第20行最后一次切换到主队列(回想一下,每一行都是在后台队列上运行的), 21降低入站值(仍然) mediaReferences), and 22 invokes totalProcessSuccess.

蜜蜂表单更清晰、更干净、更容易阅读,更不用说更容易维护了. 如果需要循环将Media对象重新集成到像map函数这样的数组中,那么该算法的长格式会发生什么情况? After you had made the change, 你有多确信算法的所有要求都能得到满足? In the HoneyBee form, 这一更改将用map替换每个函数,以使用并行映射函数. (Yes, it has reduce too.)

HoneyBee是一个强大的Swift期货库,它使编写异步和并发算法变得更容易, safer and more expressive. In this article, 我们已经看到了蜜蜂如何使你的算法更容易维护, more correct, and faster. bee还支持其他关键的异步模式,比如重试支持, multiple error handlers, resource guarding, 和集合处理(异步形式的映射), filter, and reduce). 有关功能的完整列表,请参阅 website. 要了解更多或提出问题,请参阅全新 community forums.

附录:确保异步函数的契约正确性

确保函数的契约性正确性是计算机科学的基本原则. 以至于几乎所有现代编译器都有检查来确保声明返回值的函数, returns exactly once. 返回少于一次或多于一次将被视为错误,并适当地阻止完整编译.

但是这种编译器帮助通常不适用于异步函数. 考虑以下(有趣的)例子:

func generateIcecream(from int: Int, completion: (String) -> Void) {
  if int > 5 {
    if int < 20 {
      completion("Chocolate")
    } else if int < 10 {
      completion("Strawberry")
    }
    completion("Pistachio")
  } else if int < 2 {
    completion("Vanilla")
  }
}

The generateIcecream 函数接受Int类型并异步返回String类型. swift编译器欣然接受上述形式为正确, 尽管它包含一些明显的问题. 给定某些输入,该函数可能调用补全0次、1次或2次. 使用过异步函数的程序员经常会在自己的工作中回想起这个问题的例子. What can we do? 当然,我们可以重构代码使其更整洁(这里可以使用范围用例切换)。. 但有时功能复杂性很难降低. 如果编译器可以帮助我们验证正确性,就像它对常规返回函数所做的那样,那不是更好吗?

It turns out there is a way. 观察下面的斯威夫特咒语:

func generateIcecream(from int: Int, completion: (String) -> Void) {
  let finalResult: String
  defer {completion(finalResult)}
  let completion: Void = Void()
  defer { completion }

  if int > 5 {
    if int < 20 {
      completion("Chocolate")
    } else if int < 10 {
      completion("Strawberry")
    } // else
    completion("Pistachio")
  } else if int < 2 {
    completion("Vanilla")
  }
}

在该函数顶部插入的四行代码强制编译器验证完成回调是否只调用了一次, 这意味着这个函数不再编译. What’s going on? In the first line, 我们声明但不初始化我们最终希望这个函数产生的结果. 通过不定义它,我们确保在使用它之前必须对它赋值一次, 通过声明它,我们可以确保它永远不会被赋值两次. 第二行是一个延迟,它将作为这个函数的最后一个动作执行. 来调用完成块 finalResult -在它被函数的其余部分分配之后. 第3行创建了一个名为completion的新常量,该常量隐藏回调参数. 新的补全类型为Void,它没有声明任何公共API. 这一行确保在这行之后使用补全将是一个编译器错误. 第2行上的defer是唯一允许使用完成块的方法. 第4行删除了一个编译器警告,否则会出现关于新完成常量未使用的警告.

因此,我们成功地迫使swift编译器报告这个异步函数没有履行它的契约. 让我们通过步骤来纠正它. 首先,让我们用赋值来替换对callback的所有直接访问 finalResult.

func generateIcecream(from int: Int, completion: (String) -> Void) {
  let finalResult: String
  defer {completion(finalResult)}
  let completion: Void = Void()
  defer { completion }
 
  if int > 5 {
    if int < 20 {
      finalResult = "Chocolate"
    } else if int < 10 {
      finalResult =  "Strawberry"
    } // else
    finalResult = "Pistachio"
  } else if int < 2 {
    finalResult = "Vanilla"
  }
}

现在编译器报告了两个问题:

error: AsyncCorrectness.操场:1:8:错误:常量'finalResult'在初始化之前使用
        defer {completion(finalResult)}
              ^
 
error: AsyncCorrectness.游乐场:11:3:错误:不可变值'finalResult'只能初始化一次
                finalResult = "Pistachio"

正如预期的那样,函数有一个路径 finalResult 是否被分配了0次,以及是否有一条路径被分配了不止一次. 我们解决这些问题的方法如下:

func generateIcecream(from int: Int, completion: (String) -> Void) {
  let finalResult: String
  defer {completion(finalResult)}
  let completion: Void = Void()
  defer { completion }
 
  if int > 5 {
    if int < 20 {
      finalResult = "Chocolate"
    } else if int < 10 {
      finalResult =  "Strawberry"
    } else {
      finalResult = "Pistachio"
    }
  } else if int < 2 {
    finalResult = "Vanilla"
  } else {
    finalResult = "Neapolitan"
  }
}

“开心果”被移到了一个合适的else从句中,我们意识到我们没有涵盖一般情况——当然是“那不勒斯人”.”

可以很容易地调整刚才描述的模式以返回可选值, optional errors, 或复杂类型,如常见的Result enum. 通过强制编译器验证回调函数是否只调用了一次, 我们可以断言异步函数的正确性和完整性.

Understanding the basics

  • 编程中的并发性是什么?

    并发算法(也称为并行编程)是一种算法,旨在同时执行多个(可能是许多)操作,以利用更多的硬件资源并减少总体执行时间.

  • 并发性的问题是什么?

    嵌套代码块的“厄运金字塔”形状很快就会变得笨拙, 异步错误处理可能不直观或不完整, 第三方集成的问题更加严重, 虽然是为了提高性能, 它可能导致浪费和次优性能.

聘请Toptal这方面的专家.
Hire Now
Alex Lynch

Alex Lynch

Verified Expert in Engineering
11 Years of Experience

Atlanta, GA, United States

Member since August 27, 2018

About the author

Alex是一名iOS和全栈开发专家,拥有超过11年的iOS经验和20年的应用程序开发经验.

作者都是各自领域经过审查的专家,并撰写他们有经验的主题. 我们所有的内容都经过同行评审,并由同一领域的Toptal专家验证.

Expertise

世界级的文章,每周发一次.

输入您的电子邮件,即表示您同意我们的 privacy policy.

世界级的文章,每周发一次.

输入您的电子邮件,即表示您同意我们的 privacy policy.

Toptal Developers

Join the Toptal® community.