並行集合

並發 Trie

語言

大多數並發資料結構無法保證在資料結構於巡覽期間被修改時,能進行一致的巡覽。事實上,大多數可變集合也都是如此。並發 Trie 很特別,在於它們允許您修改正在巡覽的 Trie 本身。修改只會在後續巡覽中可見。這適用於順序並發 Trie 及其並行對應項。兩者之間唯一的差別在於,前者會順序巡覽其元素,而後者會並行巡覽。

這是一個不錯的屬性,它允許您更輕鬆地撰寫一些演算法。通常,這些演算法會反覆處理元素的資料集,其中不同的元素需要不同的反覆次數才能處理。

以下範例計算一組數字的平方根。每次反覆運算都會反覆更新平方根值。平方根收斂的數字會從映射中移除。

case class Entry(num: Double) {
  var sqrt = num
}

val length = 50000

// prepare the list
val entries = (1 until length) map { num => Entry(num.toDouble) }
val results = ParTrieMap()
for (e <- entries) results += ((e.num, e))

// compute square roots
while (results.nonEmpty) {
  for ((num, e) <- results) {
    val nsqrt = 0.5 * (e.sqrt + e.num / e.sqrt)
    if (math.abs(nsqrt - e.sqrt) < 0.01) {
      results.remove(num)
    } else e.sqrt = nsqrt
  }
}

請注意,在上述巴比倫方法平方根計算中([3]),有些數字的收斂速度可能遠快於其他數字。因此,我們希望將它們從 results 中移除,以便只遍歷需要處理的那些元素。

另一個範例是廣度優先搜尋演算法,它會反覆擴充節點前緣,直到找到通往目標的某條路徑,或者沒有更多節點可以擴充。我們將 2D 映射上的節點定義為 Int 的組。我們將 map 定義為布林值的 2D 陣列,表示相應的插槽是否已佔用。然後,我們宣告 2 個並行的字典映射– open,其中包含所有必須擴充的節點(節點前緣),以及 closed,其中包含所有已擴充的節點。我們希望從地圖的角落開始搜尋,並找到通往地圖中心的道路– 我們使用適當的節點初始化 open 映射。然後,我們反覆並行擴充 open 映射中的所有節點,直到沒有更多節點可以擴充。每次擴充一個節點時,會將它從 open 映射中移除,並放置在 closed 映射中。完成後,我們輸出從目標到初始節點的路徑。

val length = 1000

// define the Node type
type Node = (Int, Int);
type Parent = (Int, Int);

// operations on the Node type
def up(n: Node) = (n._1, n._2 - 1);
def down(n: Node) = (n._1, n._2 + 1);
def left(n: Node) = (n._1 - 1, n._2);
def right(n: Node) = (n._1 + 1, n._2);

// create a map and a target
val target = (length / 2, length / 2);
val map = Array.tabulate(length, length)((x, y) => (x % 3) != 0 || (y % 3) != 0 || (x, y) == target)
def onMap(n: Node) = n._1 >= 0 && n._1 < length && n._2 >= 0 && n._2 < length

// open list - the nodefront
// closed list - nodes already processed
val open = ParTrieMap[Node, Parent]()
val closed = ParTrieMap[Node, Parent]()

// add a couple of starting positions
open((0, 0)) = null
open((length - 1, length - 1)) = null
open((0, length - 1)) = null
open((length - 1, 0)) = null

// greedy bfs path search
while (open.nonEmpty && !open.contains(target)) {
  for ((node, parent) <- open) {
    def expand(next: Node) {
      if (onMap(next) && map(next._1)(next._2) && !closed.contains(next) && !open.contains(next)) {
        open(next) = node
      }
    }
    expand(up(node))
    expand(down(node))
    expand(left(node))
    expand(right(node))
    closed(node) = parent
    open.remove(node)
  }
}

// print path
var pathnode = open(target)
while (closed.contains(pathnode)) {
  print(pathnode + "->")
  pathnode = closed(pathnode)
}
println()

在 GitHub 上有一個 Game of Life 範例,它使用 Ctries 選擇性地模擬 Game of Life 自動機中目前處於活動狀態的部分 [4]。它還包括一個基於 Swing 的 Game of Life 模擬視覺化,您可以在其中觀察調整參數如何影響效能。

並發 tries 還支援可線性化、無鎖定、常數時間的 快照 作業。此作業會建立一個新的並發 tries,其中包含特定時間點的所有元素,因此實際上會擷取 tries 在特定時間點的狀態。 快照 作業僅為並發 tries 建立一個新的根。後續更新會延遲重建與更新相關的並發 tries 部分,並讓並發 tries 的其餘部分保持完整。首先,這表示快照作業本身並不昂貴,因為它不會複製元素。其次,由於寫入時複製最佳化只會複製並發 tries 的部分,後續修改會橫向擴充。 readOnlySnapshot 方法比 快照 方法稍微有效率,但會傳回無法修改的唯讀映射。並發 tries 還支援基於快照機制的可線性化、常數時間 清除 作業。若要深入了解並發 tries 和快照如何運作,請參閱 [1] 和 [2]。

同時執行嘗試的迭代器是基於快照。在建立迭代器物件之前,會擷取同時執行嘗試的快照,因此迭代器只會在建立快照時穿越嘗試中的元素。自然地,迭代器使用唯讀快照。

size 操作也基於快照。一個直接的實作,size 呼叫只會建立一個迭代器(即快照)並穿越元素來計算它們。每次呼叫 size 因此需要線性時間,其時間長度與元素數量成正比。然而,同時執行嘗試已經最佳化,快取它們不同部分的大小,因此將 size 方法的複雜度降低為攤銷對數時間。實際上,這表示在呼叫 size 一次之後,後續呼叫 size 將需要最少的工作量,通常只重新計算自上次 size 呼叫以來已修改的嘗試分支的大小。此外,平行同時執行嘗試的大小計算會平行執行。

參考資料

  1. 具快取感知的無鎖定同時執行雜湊嘗試
  2. 具有有效非封鎖快照的同時執行嘗試
  3. 計算平方根的方法
  4. 生命遊戲模擬

此頁面的貢獻者