安全的计时器设计模式

本文讲的是安全的计时器设计模式,


本文版权所有者为 Matt Gallagher,原文链接在 Design patterns for safe timer usage。 译文的翻译和发表得到了原作者的许可。

计时器是一个非常难以正确使用的工具。

延迟调用和单次计时器使用非常简单,但它们有时会陷入无法维护的反模式,有时很容易在控制和 handler 上下文之间出现序列问题。

和我一起看看有关计时器的 bug 和潜在维护问题吧。

注意:本文的代码会使用 Swift 3 版本的 Dispatch API 演示单次计时器。但许多共通原则适用于其他各种周期计时器和异步计时器 API。

计时器的目的

计时器的问题通常在写代码之前就开始了。

有一个概念上的问题:计时器的接口看起来是要让某个功能延迟一定时间。严格来说,延迟是它们所做的事情,但绝不是其目的所在。

单次计时器的目的是为某个临时资源执行生存期结束后的操作。比如 Session 计时器到期删除 Session,超时会关闭闲置的连接,UI 计时器会删除视图元素或重置视图状态,日历事件计时器把事件从待完成变为已完成。

有时,你会发现计时器看起来只是一段延时,没有底层的临时资源。最糟的情况是期望被延时的功能可能会在一些先决条件满足之后调用。期望独立的代码在一定时间内完成是最糟糕的耦合(并且几乎总是会忽略应该触发其执行的通知)。

反之,周期计时器不一定有这样清晰定义的目的。它可能不断更新同一个资源,可能每次创建或删除单个资源,也可能不依赖持续的资源,只是做一些短暂的工作。

但即使是这种仅仅为了延迟的情况,延迟状态本身也是一个临时资源。为了状态的组合、测试、调试,所有状态都应该由数据中的数值清晰表达,延迟状态当然也不例外。

我强调计时器的目的是因为它会带来如下的要求:

  1. 一个计时器总是会与一个临时资源紧密联系。
  2. 计时器或临时资源的变化必须引发另一方的变化(即使它们并不一定同步发生

计时器的很多问题都是因为不能满足其中一个要求。

延迟调用

在 libdispatch 中,最简单的计时器就是 DispatchQueue.after。这就是一个“延迟调用”,仅仅推迟某个功能但不返回引用,所以没有可能取消。

一个基本的 after 调用看起来是这样的:

DispatchQueue.global().asyncAfter(deadline: DispatchTime.now() + .seconds(10)) {
  // 推迟的代码
}

延迟调用有时候在调试过程中很有用,但它们太容易出问题,不能放心的用到线上代码中去。

我们看看延迟调用会引发问题的一个最直观场景:

class Parent {
  let queue = DispatchQueue(label: "")
  var temporaryChild: Child? = nil

  func createChild() {
    queue.sync {
      // Construct a new, temporary value
      temporaryChild = Child()

      // Schedule cleanup after a 10 seconds
      let t = DispatchTime.now() + DispatchTimeInterval.seconds(10)
      DispatchQueue.global().asyncAfter(deadline: t) { [weak self] in
        guard let s = self else { return }

          // Delete the value when invoked
          s.queue.sync { s.temporaryChild = nil }
      }
    }
  }
}

当 temporaryChild 被创建的时候, 一个延迟调用预计会在 10.0 秒后删除它,但延迟调用和 temporaryChild 的生存期并不一致。

很容易看出哪里有问题: 执行 createChild 两次,第一个延迟调用将会删除第二个 temporaryChild.

鉴于引起维护问题的可能性,我认为 after 在线上代码中是不能使用的;你可以让代码工作,但程序容易挂掉。计时器直接作用域之外的微小变化就会打破它的行为。更糟的是,当计时器出问题时,它看起来还是正常的,可能会通过自动化测试,到了引发问题的那个时间点,程序就会崩溃。

不要在调试以外的场合使用延迟调用。

可取消计时器

可取消计时器并不比延迟调用难多少。

public extension DispatchSource {
  public class func makeTimerSource(interval: DispatchTimeInterval, handler: () -> Void)
    -> DispatchSourceTimer {
    let result = DispatchSource.makeTimerSource(queue: DispatchQueue.global())
    result.setEventHandler(handler: handler)
    result.scheduleOneshot(deadline: DispatchTime.now() + interval)
    result.resume()
    return result
 }

}

返回的 DispatchSourceTimer 如果被释放,会自动取消计时器,这样我们会有一个安全许多的设计。

class Parent {
   let queue = DispatchQueue(label: "")
   var temporaryChild: (child: Child, timer: DispatchSourceTimer)? = nil

   func createChild() {
      queue.sync {
         // Construct a new child
         let c = Child()

         // Schedule deletion
         let t = DispatchSource.makeTimerSource(interval: .seconds(10)) { [weak self] in
            guard let s = self else { return }

            // Delete the child when invoked
            s.queue.sync { s.temporaryChild = nil }
         }

         // Tie the child and timer together
         temporaryChild = (c, t)
      }
   }
}

计时器的生存期和它操作的资源的生存期绑到了一起,前面说的问题就解决了。

但是这段代码中仍然有个严重的问题。

忽略可取消计时器

前面的 Parent 例子中,对 temporaryChild 的访问都是用 queue.sync 作为互斥锁来保护的。但是,关于互斥锁的重要一点是: 互斥锁本身是不能保证代码线程安全的。

考虑下面的事件顺序:

  1. 用 createChild() 创建一个 child
  2. 10秒之后,DispatchQueue.global() 并发队列上的 handler 被调用
  3. handler启动但还没有进入 s.queue.sync
  4. 这时再次调用 createChild(),进入队列创建新的 child 和计时器,然后退出队列。
  5. 第3步中的 handler,本应删除旧的 child,进入 s.queue.sync 之后却删掉了新的 child。

旧的计时器删掉了新的 child。啊哦。

我们又回到了老问题,计时器没有和正确的 child 绑定. 任何情况下 handler 的控制和执行如果发生在互斥锁之外,就会使互斥锁的序列版本和计时器的序列版本不一致。 因为我们只关注互斥锁的序列版本,所以需要忽略那些不是最新应用到互斥锁上的计时器。具体就是改变计时器的构造方法,使 handler 接受参数,能够识别过时的计时器。

一种方式是把计时器自己的引用传递给 handler,这需要重写前面的 DispatchSource.timer 方法:

public extension DispatchSource {
 // Similar to before but we pass an instance of the timer to the handler function
 public class func makeTimerSource(interval: DispatchTimeInterval, handler:
    (DispatchSource) -> Void) -> DispatchSourceTimer {
    let result = DispatchSource.makeTimerSource(queue: DispatchQueue.global())

    // Some minor juggling with the timer instance to avoid creating a retain cycle
    let res = result as! DispatchSource
    result.setEventHandler { [weak res] in
       guard let r = res else { return }
       handler(r)
    }

    result.scheduleOneshot(deadline: DispatchTime.now() + interval)
    result.resume()
    return result
 }

}

然后你就可以使用新的计时器构造方法:

class Parent {
   let queue = DispatchQueue(label: "")
   var temporaryChild: (child: Child, timer: DispatchSourceTimer)? = nil

   func createChild() {
      queue.sync {
         // Construct a new child
         let c = Child()

         // Schedule deletion
         let t = DispatchSource.makeTimerSource(interval: .seconds(10)) {
            [weak self] (t: DispatchSource) in
            guard let s = self else { return }
            s.queue.sync {
               // Verify the identity of the timer
               guard let childTimer = s.temporaryChild?.timer,
                  t === (childTimer as AnyObject) else {
                  return
               }
               s.temporaryChild = nil
            }
         }

         // Tie the child and timer together
         temporaryChild = (c, t)
      }
   }
}

我们的 handler 现在可以识别是否是“当前的”计时器,如果不是就退出。

有世代计数的计时器

上面的代码差不多可以用了,但是还有一种情况不能处理:重设了的计时器。

重设了的计时器是指我们需要延长计时器的到期时间。例如等待计时器(sleep 或者 timeout 计时器)。对于等待计时器,每个新的 activity 都会把它重置到完整的时长。

重设的问题在于,计时器的到期时间被更改了,但计时器的实例还是原来的。如果 handler 的执行当中计时器被重设了,handler 会按照旧的到期时间执行,因为计时器的标识没变。

为了忽略取消重设的计时器,我们可以用一个“世代”计数。这个数只是一个 Int 参数,在构造和重设的时候传给DispatchSource.timer,并在 handler 被调用的时候传递给它。 我们可以和验证计时器标识一样验证世代计数,但是好处是还可以在重设的时候改变计数值,而不只是创建的时候。

它非常灵活有效,但是在每个节点都加了一层复杂度, 所以代码量几乎是上面可取消计时器的两倍

public extension DispatchSource {
   // Similar to before but we pass a user-supplied Int to the handler function
   public class func makeTimerSource(interval: DispatchTimeInterval, parameter: Int,
		handler: (parameter: Int) -> Void) -> DispatchSourceTimer {
      let result = DispatchSource.makeTimerSource(queue: DispatchQueue.global())
      result.scheduleOneshot(interval: interval, parameter: parameter, handler: handler)
      result.resume()
      return result
   }
}

public extension DispatchSourceTimer {
   // An overload of scheduleOneshot that updates the handler function with a new
   // user-supplied Int when it changes the expiry deadline
   public func scheduleOneshot(interval: DispatchTimeInterval, parameter: Int, handler:
      (parameter: Int) -> Void) {
      suspend()
      setEventHandler { handler(parameter: parameter) }
      scheduleOneshot(deadline: DispatchTime.now() + interval)
      resume()
   }
}

class Parent {
   let queue = DispatchQueue(label: "")
   var generation: Int = 0
   var temporaryChild: (child: Child, timer: DispatchSourceTimer)? = nil

   func createChild() {
      queue.sync {
         // Construct a new child
         let c = Child()

         // Increment the generation
         generation += 1

         // Schedule deletion
         let t = DispatchSource.makeTimerSource(interval: .seconds(10), parameter:
            generation) { [weak self] p in
            guard let s = self else { return }
            s.timerHandler(parameter: p)
         }

         // Tie the child and timer together
         temporaryChild = (c, t)
      }
   }

   func resetChildTimer() {
      queue.sync {
         guard temporaryChild == nil else { return }

         // Increment the generation
         generation += 1

         // Reschedule the timer
         self.temporaryChild?.timer.scheduleOneshot(interval: .seconds(10), parameter:
            generation) { [weak self] p in
            guard let s = self else { return }
            s.timerHandler(parameter: p)
         }
      }
   }

   // Since we're changing the handler each time, it helps to have a shared
   // function to create the handler
   func timerHandler(parameter: Int) {
      queue.sync {
         guard parameter == generation else { return }
         temporaryChild = nil
      }
   }
}

单队列同步计时器

我们的简单计时器现在有了很多代码,大部分都是为了过滤无效的结果。一个更好的选择是,保证计时器在同一个上下文中创建,由一把互斥锁围绕计时器和相关的临时资源,从根源上避免无效结果产生。

我们来再次重构一下 DispatchSource.timer

public extension DispatchSource {
   // Similar to before but the scheduling queue is passed as a parameter
   public class func makeTimerSource(interval: DispatchTimeInterval, queue: DispatchQueue,
      handler: () -> Void) -> DispatchSourceTimer {
      // Use the specified queue
      let result = DispatchSource.makeTimerSource(queue: queue)
      result.setEventHandler(handler: handler)

      // Unlike previous example, no specialized scheduleOneshot required
      result.scheduleOneshot(deadline: DispatchTime.now() + interval)
      result.resume()
      return result
   }
}

Parent 类可以神奇的简化:

class Parent {
   let queue = DispatchQueue(label: "")
   var temporaryChild: (child: Child, timer: DispatchSourceTimer)? = nil

   func createChild() {
      queue.sync {
         let t = DispatchSource.makeTimerSource(interval: .seconds(10), queue: queue) {
            [weak self] in
            self?.temporaryChild = nil
         }
         temporaryChild = (Child(), t)
      }
   }

   func resetChildTimer() {
      queue.sync {
         temporaryChild?.timer.scheduleOneshot(deadline: DispatchTime.now() + .seconds(10))
      }
   }
}

现在代码比原来简洁清晰很多,而且同样线程安全.

这样的计时器使用模式并不是永远可行的 - 在某些情况下,需要使用前面的“世代计数”方法。比如你选择使用不同的互斥锁(更快的互斥锁,比如我在之前文章中提到的Mutexes and closure capture in Swift)。 在其他 API 中,还有可能无法用调度队列作为同步互斥锁(比如 C++ 中的 boost::asio,用于序列化的 io_service::strand 类不能以确保同步的方式调用)。

外部需求

“世代计数”和“单队列同步”的计时器还有个问题,就是它们都有外部需求。

什么是外部需求?我指的是它们都有非功能参数的需求,需要用互斥锁来围绕计时器和相关临时资源,不然就会有同步失败的风险。

理想情况下,我们应该有一个不需要任何外部需求和前置条件的接口 - 只要你实现了接口的类型需求,接口的使用就是有效的。

在某些具体场合,这是可以做到的。最直接的方式就是把数值,计时器互斥锁包装到一个接口里。例如:

public class TimeLimitedContainer<T> {
   var possibleValue: T?
   let timer: DispatchSourceTimer
   let queue: DispatchQueue

   public init(value: T, interval: DispatchTimeInterval) {
      self.possibleValue = nil
      self.queue = DispatchQueue(label: "")
      self.timer = DispatchSource.makeTimerSource(queue: queue)

      self.timer.setEventHandler(handler: { [weak self] in self?.possibleValue = nil })
      self.timer.scheduleOneshot(deadline: DispatchTime.now() + interval)
      self.timer.resume()
   }

   public var value: T? {
      var result: T? = nil
      queue.sync { result = possibleValue }
      return result
   }

   public func resetTimer(interval: DispatchTimeInterval) {
      queue.sync {
         timer.scheduleOneshot(deadline: DispatchTime.now() + interval)
      }
   }
}

这种方式的问题是,它会限制计时器结束时可以执行的动作:这个例子中做的就是把 Optional 设为 nil。很多情况下这是不够用的。一段时间后的变化通常需要广播通知,执行刷新或者重新处理,这样内存中的其他对象可以调整到新的值。变化的传播需要在同一个互斥锁下发生,或者在避免死锁的前提下在多个互斥锁中发生。

虽然你可以把 possibleValue 成员做成一个 OnDelete 结构体(像我在这里描述的 Breaking Swift with reference counted structs),然后用 OnDelete handler 来执行任意操作。但这像是回到了一个原始的计时器。 你应该对底层计时器有另一层抽象,最终使得计时器结束时触发一个简单的 handler。

要处理一系列的级联变化传播,在锁之间进出还要保证线程安全,需要扫描整个程序中的变化。在这种情况下,可以把计时器隐藏在大的框架接口之下。具体的实现要根据变化传播的框架。

如果没有线程安全的变化传播框架,最好的方法就是上述的在使用计时器时保证外部需求。这样可以使你正确的在 Parent对象中处理变化传播。

使用

“世代计数”和“单队列同步”版本的 DispatchSource.timer 实现是我的 CwlUtils 的一部分 mattgallagher/CwlUtils. 现在在 Swift 3 prerelease 分支当中 swift3-prerelease branch,当 Swift 3 正式发布时会 merge 到 master。

文章中并没有放很多代码 – 我的重点是在代码之间的模式. 如果你需要,CwlDispatch.swift 是一个完整自包含的代码文件。

总结

有一个著名的设计准则: “你不需要它”, 指的是你应该只关注当前的需求,只要现在的代码可以工作,就别担心将来的问题. 这话有一定的道理,但是如果要处理难以测试的问题,多加小心,未雨绸缪总是必要的。

计时器有一个讨厌的倾向,看起来工作正常,但是不太相关(甚至毫不相干)的代码稍微变化,就会出问题了。由于自动化测试通常只会用一些固定的时间模式,可能时间相关的 bug 不会被发现,任何测试都通过了,但结果程序中有严重的问题。最好能有一些简单的步骤,从一开始就保证计时器在不同的使用模式下工作正常,哪怕你不需要取消或者重设计时器。

对每一个计时器:

  • 对每个计时器清晰的定义相关的“临时资源”,保证计时器和资源的变化在同一个互斥锁下面发生。
  • 所有计时器都应该可以取消,它们的生存期应该和相关的临时资源一致。
  • 计时器取消或重设触发的 handler 调用应该不可行或者没有效果。

你应该遵守这些需求,即使你不需要取消或者重设计时器。

我演示了满足上面条件的两种不同方法:“世代计数”和“单队列同步”模式的计数器使用。

后者语法上更简单,包括下面的步骤:

  1. 把计时器和相关临时资源存放到一个复合值中。
  2. 使用 DispatchQueue 作为互斥锁围绕计时器和相关临时资源。
  3. 在同一个 DispatchQueue 中启动计时器。

“世代计数”模式避免了对 DispatchQueue 作为互斥锁的要求,也不需要限制计时器的启动队列。但是它仍然需要互斥锁,并且还需要跟踪世代计数。并且这个方法更加复杂。

不幸的是,两者都有一样的烦恼:对作用域上互斥锁的要求 - 并且难以通过前置条件或其他方式确认。

想在异步环境中设计线程安全的计时器代码,还想不借助外部依赖的话,需要在整个程序的变化管理中加入更多个人的想法。我很想将来继续研究这个问题。






原文发布时间为:2016年08月13日


本文来自合作伙伴掘金,了解相关信息可以关注掘金网站。

时间: 2024-11-02 19:59:47

安全的计时器设计模式的相关文章

使用熔断器设计模式保护软件

作为软件开发人员,我们的生活是快节奏的,我们采用的是敏捷软件开发方法,迭代式的开发我们软件功能,开发完成提交测试,通过了QA的测试后被部署到生产环境,然后可怕的事情在生产环境里发生了,生产环境的压力超过了我们的设计值,也就是说过载了,这种情况经常发生在调用远程服务,因为没有做过载保护,导致请求的资源阻塞在服务器上等待从而耗尽系统或者服务器资源,很多时候刚开始的时候只是系统出现了局部的,小规模的故障,然而由于种种原因,故障的范围越来越大,最终导致了全局性的后果,墨菲定律在软件里面特别灵验.俗话说就

云计算设计模式(八)——外部配置存储模式

云计算设计模式(八)--外部配置存储模式 移动配置信息从应用部署包到一个集中位置.这个模式可以提供机会,以便管理和配置数据的控制,以及用于跨应用程序和应用程序实例共享的配置数据. 背景和问题 大多数应用程序运行时环境包括位于应用程序文件夹内的在部署应用程序文件保持配置信息.在某些情况下也能够编辑这些文件来改变该应用程序的行为,它已经被部署之后.然而,在许多情况下,改变配置所需要的应用程序被重新部署,从而导致不可接受的停机时间和额外的管理开销. 本地配置文件还配置限制为单个应用程序,而在某些情况下

云计算设计模式(二)——断路器模式

云计算设计模式(二)--断路器模式 处理故障连接到远程服务或资源时,可能需要耗费大量的时间.这种模式可以提高应用程序的稳定性和灵活性. 背景和问题 在分布式环境中,如在云,其中,应用程序执行访问远程资源和服务的操作,有可能对这些操作的失败是由于瞬时故障,如慢的网络连接,超时,或者被过度使用的资源或暂时不可用.这些故障一般之后的短时间内纠正自己,和一个强大的云应用应该准备使用的策略来处理它们,例如,通过重试模式进行说明. 但是,也可以是其中的故障是由于那些不容易预见的突发事件的情况下,这可能需要更

Android Wear计时器开发_Android

记得在2013年12月的时候,有系列文章是介绍怎么开发一个智能手表的App,让用户可以在足球比赛中记录停表时间.随着Android Wear的问世,在可穿戴设备中开发一款这样的App确实是个很不错的想法,但是按照目前对于Android Wear的架构了解来说,似乎有些困难.所以本系列文章我们就重写这个应用,带领大家进入Android Wear的世界. 本文不会长篇大论地讲解我们要开发的这款App的用途,因为我们在之前的系列文章已经深入了解过了.这么说吧,这是一个计时类应用,在比赛开始的时候开始执

Android Wear计时器开发

记得在2013年12月的时候,有系列文章是介绍怎么开发一个智能手表的App,让用户可以在足球比赛中记录停表时间.随着Android Wear的问世,在可穿戴设备中开发一款这样的App确实是个很不错的想法,但是按照目前对于Android Wear的架构了解来说,似乎有些困难.所以本系列文章我们就重写这个应用,带领大家进入Android Wear的世界. 本文不会长篇大论地讲解我们要开发的这款App的用途,因为我们在之前的系列文章已经深入了解过了.这么说吧,这是一个计时类应用,在比赛开始的时候开始执

Android UI设计的幻灯片:新的UI设计模式

文章描述:谷歌Android UI设计技巧:新的UI设计模式. 本系列文章原是Android的官方开发者博客的一份Android UI设计的幻灯片,51CTO的译者将这份教程5部分进行翻译整理,希望对Android开发者能有帮助.本文为<谷歌Android UI设计技巧>第四部分:新的UI设计模式. 本文为<谷歌Android UI设计技巧>第四部分:新的UI设计模式. [1] [2]  下一页

您的设计模式,我们的设计模式 java设计模式

http://download.csdn.net/download/yangxin00000000/3212729   您的设计模式,我们的设计模式 java设计模式

[Head First设计模式]云南米线馆中的设计模式——模版方法模式

系列文章 [Head First设计模式]山西面馆中的设计模式--装饰者模式 [Head First设计模式]山西面馆中的设计模式--观察者模式 [Head First设计模式]山西面馆中的设计模式--建造者模式 [Head First设计模式]饺子馆(冬至)中的设计模式--工厂模式 [Head First设计模式]一个人的平安夜--单例模式 [Head First设计模式]抢票中的设计模式--代理模式 [Head First设计模式]面向对象的3特征5原则 [Head First设计模式]鸭子

[设计模式实践之路](1)单例模式

[简介] 单例模式(Singleton)保证一个类仅有一个实例,并提供一个访问它的全局访问点.      实现单例模式的一个最好的方法就是让类自身负责保存它的唯一实例.这个类可以保证没有其他实例可以创建,并且它可以提供一个访问该实例的方法. [特点] 单例模式具有一下特点: 单例类只有一个实例 单例类必须自己创建自己的唯一实例 单例类必须给所有其他对象提供这一实例 [分类] 主要的就是懒汉单例,饿汉单例 [懒汉单例] package Mode; /** * Java设计模式之单例模式 * @au