|
| 1 | +--- |
| 2 | +layout: overview-large |
| 3 | +title: Многопоточные нагруженные деревья |
| 4 | + |
| 5 | +disqus: true |
| 6 | + |
| 7 | +partof: parallel-collections |
| 8 | +language: ru |
| 9 | +num: 4 |
| 10 | +--- |
| 11 | + |
| 12 | +Большинство многопоточных структур данных не гарантирует правильности последовательного перебора элементов в случае, если эта структура изменяется во время прохождения. То же верно, кстати, и в случае большинства изменяемых коллекций. Особенность многопоточных нагруженных деревьев (также известных, как префиксные деревья) -- `tries`-- заключается в том, что они позволяют модифицировать само дерево, которое в данный момент просматривается. Сделанные изменения становятся видимыми только при следующем прохождении. Так ведут себя и последовательные нагруженные деревья, и их параллельные аналоги; единственное отличие-- в том, что первые перебирают элементы последовательно, а вторые-- параллельно. |
| 13 | + |
| 14 | +Это замечательное свойство позволяет упростить ряд алгоритмов. Обычно это такие алгоритмы, в которых некоторый набор данных обрабатывается итеративно, причем для обработки различных элементов требуется различное количество итераций. |
| 15 | + |
| 16 | +В следующем примере вычисляются квадратные корни некоторого набор чисел. Каждая итерация обновляет значение квадратного корня. Числа, квадратные корни которых достигли необходимой точности, исключаются из перебираемого набора. |
| 17 | + |
| 18 | + case class Entry(num: Double) { |
| 19 | + var sqrt = num |
| 20 | + } |
| 21 | + |
| 22 | + val length = 50000 |
| 23 | + |
| 24 | + // готовим исходные данные |
| 25 | + val entries = (1 until length) map { num => Entry(num.toDouble) } |
| 26 | + val results = ParTrieMap() |
| 27 | + for (e <- entries) results += ((e.num, e)) |
| 28 | + |
| 29 | + // вычисляем квадратные корни |
| 30 | + while (results.nonEmpty) { |
| 31 | + for ((num, e) <- results) { |
| 32 | + val nsqrt = 0.5 * (e.sqrt + e.num / e.sqrt) |
| 33 | + if (math.abs(nsqrt - e.sqrt) < 0.01) { |
| 34 | + results.remove(num) |
| 35 | + } else e.sqrt = nsqrt |
| 36 | + } |
| 37 | + } |
| 38 | + |
| 39 | +Отметим, что в приведенном выше вычислении квадратных корней вавилонским методом (\[[3][3]\]) некоторые значения могут сойтись гораздо быстрее, чем остальные. По этой причине мы исключаем их из `results`, чтобы перебирались только те элементы, которые нуждаются в дальнейшей обработке. |
| 40 | + |
| 41 | +Другим примером является алгоритм поиска в ширину, который итеративно расширяет очередь перебираемых узлов до тех пор, пока или не будет найден целевой узел, или не закончатся узлы, за счет которых можно расширить поиск. Определим точку на двухмерной карте как кортеж значений `Int`. Обозначим как `map` двухмерный массив булевых значений, которые обозначают, занята соответствующая ячейка или нет. Затем объявим два многопоточных дерева-- `open`, которое содержит все точки, которые требуется раскрыть, и `closed`, в котором хранятся уже обработанные точки. Мы намерены начать поиск с углов карты и найти путь к центру-- инициализируем ассоциативный массив `open` подходящими точками. Затем будем раскрывать параллельно все точки, содержащиеся в ассоциативном массиве `open` до тех пор, пока больше не останется точек. Каждый раз, когда точка раскрывается, она удаляется из массива `open` и помещается в массив `closed`. |
| 42 | + |
| 43 | +Выполнив все это, выведем путь от целевого до стартового узла. |
| 44 | + |
| 45 | + val length = 1000 |
| 46 | + |
| 47 | + // объявляем тип Node |
| 48 | + type Node = (Int, Int); |
| 49 | + type Parent = (Int, Int); |
| 50 | + |
| 51 | + // операции над типом Node |
| 52 | + def up(n: Node) = (n._1, n._2 - 1); |
| 53 | + def down(n: Node) = (n._1, n._2 + 1); |
| 54 | + def left(n: Node) = (n._1 - 1, n._2); |
| 55 | + def right(n: Node) = (n._1 + 1, n._2); |
| 56 | + |
| 57 | + // создаем карту и целевую точку |
| 58 | + val target = (length / 2, length / 2); |
| 59 | + val map = Array.tabulate(length, length)((x, y) => (x % 3) != 0 || (y % 3) != 0 || (x, y) == target) |
| 60 | + def onMap(n: Node) = n._1 >= 0 && n._1 < length && n._2 >= 0 && n._2 < length |
| 61 | + |
| 62 | + // список open - фронт обработки |
| 63 | + // список closed - уже обработанные точки |
| 64 | + val open = ParTrieMap[Node, Parent]() |
| 65 | + val closed = ParTrieMap[Node, Parent]() |
| 66 | + |
| 67 | + // добавляем несколько стартовых позиций |
| 68 | + open((0, 0)) = null |
| 69 | + open((length - 1, length - 1)) = null |
| 70 | + open((0, length - 1)) = null |
| 71 | + open((length - 1, 0)) = null |
| 72 | + |
| 73 | + // "жадный" поиск в ширину |
| 74 | + while (open.nonEmpty && !open.contains(target)) { |
| 75 | + for ((node, parent) <- open) { |
| 76 | + def expand(next: Node) { |
| 77 | + if (onMap(next) && map(next._1)(next._2) && !closed.contains(next) && !open.contains(next)) { |
| 78 | + open(next) = node |
| 79 | + } |
| 80 | + } |
| 81 | + expand(up(node)) |
| 82 | + expand(down(node)) |
| 83 | + expand(left(node)) |
| 84 | + expand(right(node)) |
| 85 | + closed(node) = parent |
| 86 | + open.remove(node) |
| 87 | + } |
| 88 | + } |
| 89 | + |
| 90 | + // выводим путь |
| 91 | + var pathnode = open(target) |
| 92 | + while (closed.contains(pathnode)) { |
| 93 | + print(pathnode + "->") |
| 94 | + pathnode = closed(pathnode) |
| 95 | + } |
| 96 | + println() |
| 97 | + |
| 98 | +На GitHub есть пример реализации игры "Жизнь", который использует многопоточные хэш-деревья-- `Ctries`, чтобы выборочно симулировать только те части механизма игры, которые в настоящий момент активны \[[4][4]\]. |
| 99 | +Он также включает в себя основанную на `Swing` визуализацию, которая позволяет посмотреть, как подстройка параметров влияет на производительность. |
| 100 | + |
| 101 | +Многопоточные нагруженные деревья также поддерживают атомарную, неблокирующую операцию `snapshot`, выполнение которой осуществляется за постоянное время. Эта операция создает новое многопоточное дерево со всеми элементами на некоторый выбранный момент времени, создавая таким образом снимок состояния дерева в этот момент. |
| 102 | +На самом деле, операция `snapshot` просто создает новый корень дерева. Последующие изменения отложенно перестраивают ту часть многопоточного дерева, которая соответствует изменению, и оставляет нетронутой ту часть, которая не изменилась. Прежде всего это означает, что операция 'snapshot' сама по себе не затратна, так как не происходит копирования элементов. Кроме того, так как оптимизация "копирования при записи" создает копии только измененных частей дерева, последующие модификации горизонтально масштабируемы. |
| 103 | +Метод `readOnlySnapshot` чуть более эффективен, чем метод `snapshot`, но он возвращает неизменяемый ассоциативный массив, который доступен только для чтения. Многопоточные деревья также поддерживают атомарную операцию постоянного времени `clear`, основанную на рассмотренном механизме снимков. |
| 104 | +Чтобы подробнее узнать о том, как работают многопоточные деревья и их снимки, смотрите \[[1][1]\] и \[[2][2]\]. |
| 105 | + |
| 106 | +На рассмотренном механизме снимков основана работа итераторов многопоточных деревьев. Прежде чем будет создан объект-итератор, берется снимок многопоточного дерева. Таким образом, итератор перебирает только те элементы дерева, которые присутствовали на момент создания снимка. Фактически, итераторы используют те снимки, которые дают доступ только на чтение. |
| 107 | + |
| 108 | +На том же механизме снимков основана операция `size`. В качестве примитивной реализации этой операции можно просто создать итератор (то есть, снимок) и перебрать все элементы, подсчитывая их. Таким образом, каждый вызов операции `size` будет требовать времени, прямо пропорционального числу элементов. Однако, многопоточные деревья в целях оптимизации кэшируют размеры своих отдельных частей, тем самым уменьшая временную сложность метода `size` до амортизированно-логарифмической. В результате получается, что если вызвать метод `size` один раз, можно осуществлять последующие вызовы `size` затрачивая минимум ресурсов, вычисляя, как правило, размеры только тех частей, которые изменились после последнего вызова `size`. Кроме того, вычисление размера параллельных многопоточных деревьев выполняется параллельно. |
| 109 | + |
| 110 | + |
| 111 | +## Ссылки |
| 112 | + |
| 113 | +1. ["Cache-Aware" неблокирующие многопоточные хэш-деревья][1] |
| 114 | +2. [Многопоточные деревья, поддерживающие эффективные неблокирующие снимки][2] |
| 115 | +3. [Методы вычисления квадратных корней][3] |
| 116 | +4. [Симуляция игры "Жизнь"][4] |
| 117 | + |
| 118 | + [1]: http://infoscience.epfl.ch/record/166908/files/ctries-techreport.pdf "Ctries-techreport" |
| 119 | + [2]: http://lampwww.epfl.ch/~prokopec/ctries-snapshot.pdf "Ctries-snapshot" |
| 120 | + [3]: http://en.wikipedia.org/wiki/Methods_of_computing_square_roots#Babylonian_method "babylonian-method" |
| 121 | + [4]: https://github.com/axel22/ScalaDays2012-TrieMap "game-of-life-ctries" |
0 commit comments