Copy-on-Write模式:寫時複製

話題:Copy-on-Write模式:不是延時策略的COW

在上一篇文章中我們講到Java裏String這個類在實現replace()方法的時候,並沒有更改原字符串裏面value[]數組的內容,而是創建了一個新字符串,這種方法在解決不可變對象的修改問題時經常用到。如果你深入地思考這個方法,你會發現它本質上是一種Copy-on-Write方法。所謂Copy-on-Write,經常被縮寫爲COW或者CoW,顧名思義就是寫時複製

不可變對象的寫操作往往都是使用Copy-on-Write方法解決的,當然Copy-on-Write的應用領域並不侷限於Immutability模式。下面我們先簡單介紹一下Copy-on-Write的應用領域,讓你對它有個更全面的認識。

Copy-on-Write模式的應用領域

介紹過CopyOnWriteArrayListCopyOnWriteArraySet這兩個Copy-on-Write容器,它們背後的設計思想就是Copy-on-Write;通過Copy-on-Write這兩個容器實現的讀操作是無鎖的,由於無鎖,所以將讀操作的性能發揮到了極致。

除了Java這個領域,Copy-on-Write在操作系統領域也有廣泛的應用。

Copy-on-Write在操作系統領域。類Unix的操作系統中創建進程的API是fork(),傳統的fork()函數會創建父進程的一個完整副本,例如父進程的地址空間現在用到了1G的內存,那麼fork()子進程的時候要複製父進程整個進程的地址空間(佔有1G內存)給子進程,這個過程是很耗時的。而Linux中的fork()函數就聰明得多了,fork()子進程的時候,並不複製整個進程的地址空間,而是讓父子進程共享同一個地址空間;只用在父進程或者子進程需要寫入的時候纔會複製地址空間,從而使父子進程擁有各自的地址空間。

本質上來講,父子進程的地址空間以及數據都是要隔離的,使用Copy-on-Write更多地體現的是一種延時策略只有在真正需要複製的時候才複製,而不是提前複製好,同時Copy-on-Write還支持按需複製,所以Copy-on-Write在操作系統領域是能夠提升性能的。相比較而言,Java提供的Copy-on-Write容器,由於在修改的同時會複製整個容器,所以在提升讀操作性能的同時,是以內存複製爲代價的。這裏你會發現,同樣是應用Copy-on-Write,不同的場景,對性能的影響是不同的。

在操作系統領域,除了創建進程用到了Copy-on-Write,很多文件系統也同樣用到了,例如Btrfs (B-Tree File System)、aufs(advanced multi-layered unification filesystem)等。

除了上面我們說的Java領域、操作系統領域,很多其他領域也都能看到Copy-on-Write的身影:Docker容器鏡像的設計是Copy-on-Write,甚至分佈式源碼管理系統Git背後的設計思想都有Copy-on-Write……

不過,Copy-on-Write最大的應用領域還是在函數式編程領域。函數式編程的基礎是不可變性(Immutability),所以函數式編程裏面所有的修改操作都需要Copy-on-Write來解決。你或許會有疑問,“所有數據的修改都需要複製一份,性能是不是會成爲瓶頸呢?”你的擔憂是有道理的,之所以函數式編程早年間沒有興起,性能絕對拖了後腿。但是隨着硬件性能的提升,性能問題已經慢慢變得可以接受了。而且,Copy-on-Write也遠不像Java裏的CopyOnWriteArrayList那樣笨:整個數組都複製一遍。Copy-on-Write也是可以按需複製的,如果你感興趣可以參考Purely Functional Data Structures這本書,裏面描述了各種具備不變性的數據結構的實現。

CopyOnWriteArrayList和CopyOnWriteArraySet這兩個Copy-on-Write容器在修改的時候會複製整個數組,所以如果容器經常被修改或者這個數組本身就非常大的時候,是不建議使用的。反之,如果是修改非常少、數組數量也不大,並且對讀性能要求苛刻的場景,使用Copy-on-Write容器效果就非常好了。結合一個案例來熟系一下。

案例

一個RPC框架,服務提供方是多實例分佈式部署的,所以服務的客戶端在調用RPC的時候,會選定一個服務實例來調用,這個選定的過程本質上就是在做負載均衡,而做負載均衡的前提是客戶端要有全部的路由信息。例如在下圖中,A服務的提供方有3個實例,分別是192.168.1.1、192.168.1.2和192.168.1.3,客戶端在調用目標服務A前,首先需要做的是負載均衡,也就是從這3個實例中選出1個來,然後再通過RPC把請求發送選中的目標實例。
在這裏插入圖片描述
RPC框架的一個核心任務就是維護服務的路由關係,我們可以把服務的路由關係簡化成下圖所示的路由表。當服務提供方上線或者下線的時候,就需要更新客戶端的這張路由表。
在這裏插入圖片描述
我們首先來分析一下如何用程序來實現。每次RPC調用都需要通過負載均衡器來計算目標服務的IP和端口號,而負載均衡器需要通過路由表獲取接口的所有路由信息,也就是說,每次RPC調用都需要訪問路由表,所以訪問路由表這個操作的性能要求是很高的。不過路由表對數據的一致性要求並不高,一個服務提供方從上線到反饋到客戶端的路由表裏,即便有5秒鐘,很多時候也都是能接受的(5秒鐘,對於以納秒作爲時鐘週期的CPU來說,那何止是一萬年,所以路由表對一致性的要求並不高)。而且路由表是典型的讀多寫少類問題,寫操作的量相比於讀操作,可謂是滄海一粟,少得可憐。

通過以上分析,你會發現一些關鍵詞:對讀的性能要求很高,讀多寫少,弱一致性。它們綜合在一起,你會想到什麼呢?CopyOnWriteArrayListCopyOnWriteArraySet天生就適用這種場景啊。所以下面的示例代碼中,RouteTable這個類內部我們通過ConcurrentHashMap<String, CopyOnWriteArraySet<Router>>這個數據結構來描述路由表,ConcurrentHashMap的Key是接口名,Value是路由集合,這個路由集合我們用是CopyOnWriteArraySet。

下面我們再來思考Router該如何設計,服務提供方的每一次上線、下線都會更新路由信息,這時候你有兩種選擇。一種是通過更新Router的一個狀態位來標識,如果這樣做,那麼所有訪問該狀態位的地方都需要同步訪問,這樣很影響性能。另外一種就是採用Immutability模式,每次上線、下線都創建新的Router對象或者刪除對應的Router對象。由於上線、下線的頻率很低,所以後者是最好的選擇。如果是經常上下線的話,可以用Immutability模式,一個布爾類型字段表示是否下線,可以用原子字段更新的api對它進行操作;

Router的實現代碼如下所示,是一種典型Immutability模式的實現,需要你注意的是我們重寫了equals方法,這樣CopyOnWriteArraySet的add()和remove()方法才能正常工作。

//路由信息
public final class Router{

  private final String  ip;
  private final Integer port;
  private final String  iface;
  
  //構造函數
  public Router(String ip, Integer port, String iface){
    this.ip = ip;
    this.port = port;
    this.iface = iface;
  }
  
  //重寫equals方法
  public boolean equals(Object obj){
    if (obj instanceof Router) {
      Router r = (Router)obj;
      return iface.equals(r.iface) &&
             ip.equals(r.ip) &&
             port.equals(r.port);
    }
    return false;
  }
  public int hashCode() {
    //省略hashCode相關代碼
  }
}

//路由表信息
public class RouterTable {

  //Key:接口名
  //Value:路由集合
  ConcurrentHashMap<String, CopyOnWriteArraySet<Router>> 
                   rt = new ConcurrentHashMap<>();
                   
  //根據接口名獲取路由表
  public Set<Router> get(String iface){
    return rt.get(iface);
  }
  
  //刪除路由
  public void remove(Router router) {
    Set<Router> set=rt.get(router.iface);
    if (set != null) {
      set.remove(router);
    }
  }
  //增加路由
  public void add(Router router) {
    Set<Router> set = rt.computeIfAbsent(route.iface, r -> 
        new CopyOnWriteArraySet<>());
    set.add(router);
  }
}

總結

目前Copy-on-Write在Java併發編程領域知名度不是很高,很多人都在無意中把它忽視了,但其實Copy-on-Write纔是最簡單的併發解決方案。它是如此簡單,以至於Java中的基本數據類型String、Integer、Long等都是基於Copy-on-Write方案實現的。

Copy-on-Write是一項非常通用的技術方案,在很多領域都有着廣泛的應用。不過,它也有缺點的,那就是消耗內存,每次修改都需要複製一個新的對象出來,好在隨着自動垃圾回收(GC)算法的成熟以及硬件的發展,這種內存消耗已經漸漸可以接受了。所以在實際工作中,如果寫操作非常少,那你就可以嘗試用一下Copy-on-Write,效果還是不錯的。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章