Dirty Flag 模式及其应用

  之前在开发中就发现“dirty”是一种不错的解决方案:可以用来延缓计算或者避免不必要的计算。后来在想,这应该也算一种设计模式吧,于是搜索“Dirty设计模式”,没有什么结果,然后换成英文“Dirty design pattern”,搜到了《game programming patterns》这本电子书。书中介绍了Dirty Flag 模式在游戏客户端的应用场景,如果英文不好,这里也有中文翻译。本文结合几个具体的例子,介绍什么是Dirty Flag 模式,并分析该模式的适用场景以及使用注意事项。

什么是Dirty Flag:

  简单来说,就是用一个标志位(flag)来表示一组数据的状态,这些数据要么是用来计算,或者用来需要同步。在满足条件的时候设置标志位,然后需要的时候检查(check)标志位。如果设置了标志位,那么表示这组数据处于dirty状态,这个时候需要重新计算或者同步。如果flag没有被设置,那么可以不计算(或者利用缓存的计算结果)。另外,在两次check之间,即使有多次标志位的设置,也只需要计算一次。

  因此,Dirty Flag模式的本质作用在于:延缓计算或数据同步,甚至减少无谓的计算或者同步。计算比较容易理解,对于同步,后面也会给出例子。在后面的描述中,除非特殊说明,计算也包含了同步。

Dirty Flag使用实例:

  首先,《game programming pattern》中的例子非常形象生动,图文并茂,建议直接阅读原文,本文不再复述。接下来介绍几个其他的例子。

First

    游戏开发中,有大量的物体(Entity)需要每帧tick(AI、位移),每次tick的时候检查一些条件然后做出反应。对于一些entity,可能tick检查之后发现什么都不用做,但每次tick检查也比较耗时,而且出现这种情况的概率还很高。
    利用dirty可以改造一些
1 def set_need_tick(self, is_need):
2     self.need_tick = is_need
3 
4 def tick(self):
5     if self.need_tick:
6         self.do_tick() # do_tick 需要做大量的检查,较为耗时
  上面的代码每次调用tick的时候用一次条件判断,还可能嵌套一次函数调用,修改后代码如下:
1 def dummy_tick(self):
2     pass
3 def set_need_tick(self, is_need):
4     if is_need:
5         self.tick = self.do_tick
6     else:
7         self.tick = self.dummy_tick
    不过上述的代码也是空间换时间,因为每个实例都增加了一个tick属性(原来是类属性)

Second

  之前在看bottle的代码时,看到了下面这个property,其作用是在首次调用的时候计算属性的值,之后就不用重新计算了。
 1 class cached_property(object):
 2     """ A property that is only computed once per instance and then replaces
 3         itself with an ordinary attribute. Deleting the attribute resets the
 4         property. """
 5  
 6     def __init__(self, func):
 7         update_wrapper(self, func)
 8         self.func = func
 9  
10     def __get__(self, obj, cls):
11         if obj is None: return self
12         value = obj.__dict__[self.func.__name__] = self.func(obj)
13         return value
  如果一时不能理解上面的代码,可以参见这篇文章《python属性查找》。bottle中这个例子,前提是这个属性一旦计算了就不会再重新计算,如果应用场景在某些情况下需要重新计算呢?那么可以增加这么一个函数:
1     def set_property_dirty(self, property_name):
2         self.__dict__.pop(property_name, None)

  在需要的时候调用这个设置函数就行了,在这个例子中,并没有对某个属性的设置和检查,但配合之前的cached_property,作用是很明显的:缓存计算结果,需要的时候重新计算。下面是完整测试代码

  
 1 import functools, time
 2 class cached_property(object):
 3     """ A property that is only computed once per instance and then replaces
 4         itself with an ordinary attribute. Deleting the attribute resets the
 5         property. """
 6 
 7     def __init__(self, func):
 8         functools.update_wrapper(self, func)
 9         self.func = func
10 
11     def __get__(self, obj, cls):
12         if obj is None: return self
13         value = obj.__dict__[self.func.__name__] = self.func(obj)
14         return value
15 
16 class TestClz(object):
17     @cached_property
18     def complex_calc(self):
19         print 'very complex_calc'
20         return sum(range(100))
21 
22     def __set_property_dirty(self, property_name = 'complex_calc'):
23         self.__dict__.pop(property_name, None)
24 
25     def some_action_effect_property(self):
26         self.__set_property_dirty()
27 
28     
29 
30 if __name__=='__main__':
31     t = TestClz()
32     print '>>> first call'
33     print t.complex_calc
34     print '>>> second call'
35     print t.complex_calc
36     print '>>>third call'
37     t.some_action_effect_property()
38     print t.complex_calc
cache property and dirty

 

Third

  游戏数据存档,游戏中的大量对象都需要持久化(存档),存档有各种不同的策略。第一种,每次属性变化的时候存档,这样数据不容易丢失,但是往往会存在冗余,数据库压力也较大,比如属性A的变化影响到属性B(经验的增加导致等级的变化),那么属性A变化的时候进行一次存档,属性B变换的时候又要存档。另一种策略是,定期存档,即以固定的时间间隔进行一次存档,当然可以进一步,在需要持久话的数据变化时设置dirty flag,在定期存档的时机只有设置了dirty flag才真正存储。
 

Fourth

  这个例子是Dirty Flag的升级版本,暂且称之为tag Flag吧。比如页面上有一些图表,图表是通过大量数据的数据计算然后绘制出来的,图表内容可以通过用户主动点击刷新或者定时刷新。简单的策略是每次刷新的时候服务器返回所有数据,浏览器重新显示。但事实上服务端数据变化可能不那么频繁,既浪费了大量的带宽,又让浏览器无谓的重复绘制。
  其中一种解决的办法,就是为数据增加一个签名--tag(自增整型),对于服务器端,每次数据变化的时候tag += 1。浏览器首次请求的时候获取数据以及当前数据对应的tag,之后请求的时候携带tag与服务端的tag做比较,若tag一致,则无需更新数据,否则返回新的数据和新的tag。这样,所有的客户端都能利用这个tag来决定自己是否要刷新数据。
 

Fifth

  Dirty在web前端还有许多其他应用,比如angularJSKnockoutJS,由于本人并不熟悉web前端,感兴趣的读者可以参考链接

适用场景:

    正如《game programming patterns》中的归类, Dirty Flag pattern属于optimization pattern,只有需要优化的时候才考虑使用该模式。有人说,”过早的优化是万恶之源“,我觉得这对于Dirty Flag还是比较合适的,Dirty Flag的使用不是那么自然,跟业务逻辑本身也是无关的,只有在Profile确定瓶颈之后再来考虑是否可以用Dirty Flag优化。
    某些计算(或者同步)较为昂贵且频繁,但事实上很多情况无需计算(或者同步),通过Dirty Flag来标志真正需要计算(或者同步)的情况,降低开销。
 

使用条件:

  当满足下面所有条件,或者说权衡下面的所有条件之后还可以接受,那么才建议使用Dirty Flag模式。
第一:单次计算的开销足够大
    这个是首要条件,如果每次计算的开销非常小,那么就没有必要用Dirty Flag来优化了,因为增加标志位既增加了编码复杂度,又带来了一定的开销(标志位的设置与清除)。单次计算的开销可以通过profile来确定。
 
第二:事实上需要计算的概率足够低
  我们还是以游戏为例,假设游戏的是60帧,即每秒tick60次。如果一个计算每次(每帧)都有很大的概率“必须重新计算,那dirty flag反而增加了额外的开销。
 
第三:延迟计算没有副作用
  如《game programming pattern》中的例子所示,Dirty Flag在这个例子导致延迟计算,延迟计算会将分散在不同时间进行的计算集中到检查的时刻,这样可能带来一些副作用,比如造成游戏卡顿。另外,对于游戏存档的例子,如果在两次定期存储之间服务器宕机,可能会数据丢失。
 
第四:内存换速度的代价
    使用dirty模式,很多时候需要缓存结果,这需要额外的内存,对于某些场景,还有缓存命中率的问题,在内存稀缺的移动设备上尤其需要权衡。
 

注意事项:

第一:标志位的设置必须覆盖到所有可能影响的地方
  如果某些操作遗漏了对标志位的位置,那么往往会导致严重的错误。这个也跟标志位的粒度有关。大多数情况都是因为对某个属性的修改,导致需要重新计算,在python语言中要监控到属性的修改还是很容易的,可以虫子__setattr__函数,或者使用property descriptor
 
第二:计算之后reset标志位
 
 
references:
posted @ 2017-02-15 13:46 xybaby 阅读(...) 评论(...) 编辑 收藏