MENU

Swift「信号」机制概述

作者:Federico Zanetello 翻译:BigNerdCoding,原文链接

首先,如果你对大中枢派发(GCD)和派发队列不够熟悉的话,请先看 AppCoda 的这篇文章

在了解了 GCD 内容后,接下来我们来看看 Swift 中的信号机制。

Cover

简介

先让我们假象一个场景:有一群作者在写作的时候必须共享一支笔来完成个人的工作。很明显在这种情形下,每次都只能有一个人能够进行写作。

在代码世界中,上述场景中写作者就相当于线程而就是需要共享的资源(例如:文件、变量、某种权限)。

那么问题来了,如何保证这些共享资源的互斥使用?

2

共享资源的访问控制

对于这些资源的互斥使用问题有人可能会想,只需一个 Bool 类型的 resourceIsAvailable 变量就足够了:

if (resourceIsAvailable) {
  resourceIsAvailable = false
  useResource()
  resourceIsAvailable = true
} else {
  // resource is not available, wait or do something else
}

但是在多线程并发的情况下(不考虑优先级),我们是无法得知具体是哪个线程在执行上述代码。

示例

例如,现在有两个线程 threadA、threadB 都要执行上面的代码,并且对资源的使用是互斥的。那么就可能出现以下情形:

  • threadA 首先执行了条件判断语句,并且得到的资源的访问权限。

  • 但是在执行权限锁定的代码之前( resourceIsAvailable = false),处理器切换到 threadB 并且也执行了条件判断语句。

  • 现在两个线程都有了访问权限,这就导致了很严重的问题。

所以,不使用 GCD 就想完成线程安全代码的编写是一件非常困难的事情。

3

How Semaphores Work:

简单来说,分为三个步骤:

  1. 当你需要使用共享资源的时候,首先给型号机制发送权限请求(request)。

  2. 当信号机制对你开启绿灯放行的时候,我们就可以确保当前资源已经能够被我们使用。

  3. 当资源使用完毕后,你必须给信号机制发送通知(signal),让它回收权限并再次分派给其他线程。

当共享资源只有一份并且只能被一个线程占有的时候,那么你可以将上面的 request/signal 理解为对资源的 lock/unlock

4

幕后的运行机制

The Structure

首先信号机制需要一个信号量来控制访问权限,它的组成如下:

  • 一个计数器 counter 用于标记可用资源数,也就是说它表示了当前还有多少资源还能被线程使用。

  • 一个 FIFO 的线程派发队列,用于处理等待资源访问权限的线程。

Resource Request:wait()

当信号机制接受到请求后,它会先去检查自己的资源计数是否大于 0:

  • 如果大于0,则资源计数减 1 ,并将资源分配给请求者使用。

  • 如果不满足,则将该请求线程放到请求队列的最后。

Resource Release:signal()

当信号机制收到一个使用完毕的释放消息时,他会先去检查请求队列:

  • 如果请求队列里的线程不为空的话,则将队列中的第一个线程移出并将资源分配给该线程。

  • 否则则增加资源计数。

Warning: Busy Waiting

当线程向信号机制请求资源分配但是没有得到满足时,该线程将会被冻结直到成功获取了资源的使用权。

⚠️ 如果该线程是主线程的话,那么整个 App 都将会被冻结失去响应。

5

信号机制在 Swift 中的使用

说了那么多,下面我们通过代码来更好的理解该机制。

Declaration

信号量结构的声明非常的简单:

let semaphore = DispatchSemaphore(value: 1)

其中的参数 value,表示了可供使用的资源总数。

Resource Request

请求资源分配也非常的简单:

semaphore.wait()

需要注意的是,该信号量并没有给予线程任何物理资,仅仅只是一个使用权限。线程只能在 requestrelease 操作之间对资源进行使用。

一旦线程获得了访问权限,那么我们就可以假定线程一定能够对资源进行正常操作。

Resource Release

在释放资源的时候,我们这样写:

semaphore.signal()

当完成资源释放后,该线程就无法使用该资源了,除非它再次发起使用请求。

Semaphore Playgrounds

与AppCoda 的这篇文章一样,接下来我们看看信号机制的真实使用场景。

因为 Swift Playgrounds 并不能完美支持,所以这里我们使用的是 Xcode Playgrounds。希望 WWDC17 中苹果能够对 Swift Playgrounds 进行功能提升吧。

在下面的 playgrounds 中会创建两个线程并且两者将赋予不同的优先级,然后执行的时候打印十次 emoji。

非信号机制下的情形

import Foundation
import PlaygroundSupport

let higherPriority = DispatchQueue.global(qos: .userInitiated)
let lowerPriority = DispatchQueue.global(qos: .utility)

func asyncPrint(queue: DispatchQueue, symbol: String) {
  queue.async {
    for i in 0...10 {
      print(symbol, i)
    }
  }
}

asyncPrint(queue: higherPriority, symbol: "?")
asyncPrint(queue: lowerPriority, symbol: "?")

PlaygroundPage.current.needsIndefiniteExecution = true

正如预料中的那样,高优先级的线程早于低优先级的线程结束任务:

6

采用信号机制

接下来我们对上面的代码进行改写,在其中加入信号机制。为此我们需要定义一个信号量并对其中的 asyncPrint 进行修改:

import Foundation
import PlaygroundSupport

let higherPriority = DispatchQueue.global(qos: .userInitiated)
let lowerPriority = DispatchQueue.global(qos: .utility)

let semaphore = DispatchSemaphore(value: 1)

func asyncPrint(queue: DispatchQueue, symbol: String) {
  queue.async {
    print("\(symbol) waiting")
    semaphore.wait()  // requesting the resource
    
    for i in 0...10 {
      print(symbol, i)
    }
    
    print("\(symbol) signal")
    semaphore.signal() // releasing the resource
  }
}

asyncPrint(queue: higherPriority, symbol: "?")
asyncPrint(queue: lowerPriority, symbol: "?")

PlaygroundPage.current.needsIndefiniteExecution = true

为了查看每个线程在执行时的真实状态,我们在代码中打印了更多的信息。

7

如上所示,当你开始打印出某个线程的执行状态的时候,另一个线程必须等待前者执行结束才能得到执行。不管第二个进程时于何时发送了 wait() 请求,它都必须等待第一个进程的执行结束释放资源。

优先级逆转

现在我们知道了信号机制是如何工作的,接下来我们检查下面的打印信息:

8

图示的情形是因为在执行上面的代码时,处理器优先选择了低优先级的进程。当这种情形发生的时候高优先级的进程也必须等待资源的释放。这种情形在代码中完全有可能发生,而这种情形在编程世界中被称为优先级反转

在与信号量机制不同的其他编程概念中,上述情形发生后低优先级的线程会暂时继承所有等待进程中的优先级最高进程的优先级,这被称为优先级继承

饥饿线程

现在设想一个更糟糕的情况,在当前最高和最低优先级线程中间还存在大量的默认优先级线程。在上面的优先级反转的情形下,高优先级线程排在低优先级线程之后,但是与此同时大量的默认优先级又有可能排在低优先级线程之前(毕竟优先级高)。

这种可能的状况出现后,就会导致高优先级线程长时间处于饥饿的等待状态。

解决方案

在我看来,信号机制应该在所有资源竞争线程的优先级相同的情形下使用。如果不满足该条件的话,我建议你看看 RegionsMonitors

死锁

下面的示例中将有两个线程都需要独占资源 A、B 的使用权。

如果两个资源可以单独使用,则为每个资源定义一个信号量是有意义的。 如果不行的话则使用一个信号量进行管理。

在前一种情况(2资源,2信号量)下可能出现如下情况:高优先级线程将使用第一个资源“A”,然后是“B”,而我们的低优先级线程将使用第一个资源“B” 然后是“A”。

代码如下:

import Foundation
import PlaygroundSupport

let higherPriority = DispatchQueue.global(qos: .userInitiated)
let lowerPriority = DispatchQueue.global(qos: .utility)

let semaphoreA = DispatchSemaphore(value: 1)
let semaphoreB = DispatchSemaphore(value: 1)

func asyncPrint(queue: DispatchQueue, symbol: String, firstResource: String, firstSemaphore: DispatchSemaphore, secondResource: String, secondSemaphore: DispatchSemaphore) {
  func requestResource(_ resource: String, with semaphore: DispatchSemaphore) {
    print("\(symbol) waiting resource \(resource)")
    semaphore.wait()  // requesting the resource
  }
  
  queue.async {
    requestResource(firstResource, with: firstSemaphore)
    for i in 0...10 {
      if i == 5 {
        requestResource(secondResource, with: secondSemaphore)
      }
      print(symbol, i)
    }
    
    print("\(symbol) releasing resources")
    firstSemaphore.signal() // releasing first resource
    secondSemaphore.signal() // releasing second resource
  }
}

asyncPrint(queue: higherPriority, symbol: "?", firstResource: "A", firstSemaphore: semaphoreA, secondResource: "B", secondSemaphore: semaphoreB)
asyncPrint(queue: lowerPriority, symbol: "?", firstResource: "B", firstSemaphore: semaphoreB, secondResource: "A", secondSemaphore: semaphoreA)

PlaygroundPage.current.needsIndefiniteExecution = true

如果幸运的话:

9

简单来说,高优先级的线程得到了两个资源的使用权并在执行完成后低优先级的线程继续执行。

但是,如果运气不好的话:

10

两个线程都无法完成任务,他们都在等待对方释放其手中的资源使用权。这就是计算机中死锁概念。

解决方法

实话说,死锁在真实世界中是很难处理的一个问题。所有,我们在一开始的时候就应该尽量避免这种情况的发生。例如上例中我们可以将两个资源捆绑在一起做为一个信号量,虽然在效率上可能存在一定的牺牲。

另外,在一些系统中当发生死锁时,系统会将其中某个线程干掉来打破这种状态。

或者你可以使用 Ostrich algorithm

标签: 无