Тесты производительности Event, ActionList, Observer, InterfaceObserver

Иногда нам нужно вызвать несколько методов как реакцию на событие - что стоит использовать в этом случае, чтобы уменьшить потери в производительности? Ответ не совсем очевиден.

Тестовое окружение

Для тестов прогоним 10000 итераций вызовов события, повторим этот процесс 10 раз и затем усредним результат. Т.к мы это будем делать в Unity, то нужно учитывать следующее:

  • Мы не можем использовать Time.realtimeSinceStartup для измерений - точность таких данных будет очень низкой. Вместо этого следует использовать стандартный класс System.Diagnostics.Stopwatch.
  • Мы не можем начинать измерение прямо на старте - Unity требуется какое-то время для полной инициализации (подгрузки кода, графических ресурсов и т.п), необходимо выждать какое-то время, пока аппаратные ресурсы не освободятся. 3 секунд будет достаточно для этого теста.
  • Мы не можем доверять измерениям внутри редактора Unity, т.к код в этом случае будет работать в DEBUG-режиме и без оптимизаций. Для более корректного измерения нам необходим RELEASE-режим - для этого придется собрать билд приложения и запустить его вне редактора. Для фиксирования замеров будем использовать стандартный Debug.Log-метод и смотреть сформировавшиеся логи.

Event

Первый вариант - встроенный в C# event механизм обработки можественной подписки на события:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
class EventTest : MonoBehaviour {
const int TESTS = 10;
const int T = 10000;
public static int Counter;

event Action _event;

void TestCB () {
EventTest.Counter = (EventTest.Counter + 1) % 10000;
}

IEnumerator Start () {
yield return new WaitForSeconds (3f);
var result = 0;
var sw = new System.Diagnostics.Stopwatch ();
for (var i = 0; i < T; i++) {
_events += TestCB;
}
for (var test = 0; test < TESTS; test++) {
sw.Reset ();
sw.Start ();
for (var i = 0; i < T; i++) {
_events ();
}
sw.Stop ();
result += _sw.Elapsed.Milliseconds;
}
Debug.LogFormat ("event call: {0:F2}", result / (float) TESTS);
}
}

ActionList

Второй вариант - сохранение подписчиков в List<System.Action> с ручным вызовом в цикле:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
class ActionListTest : MonoBehaviour {
const int TESTS = 10;
const int T = 10000;
public static int Counter;

List<Action> _actions = new List<Action> ();

void TestCB () {
EventTest.Counter = (EventTest.Counter + 1) % 10000;
}

IEnumerator Start () {
yield return new WaitForSeconds (3f);
var result = 0;
var sw = new System.Diagnostics.Stopwatch ();
for (var i = 0; i < T; i++) {
_actions.Add (TestCB);
}
for (var test = 0; test < TESTS; test++) {
sw.Reset ();
sw.Start ();
for (var i = 0; i < T; i++) {
for (int j = 0; j < _actions.Count; j++) {
_actions[j] ();
}
}
sw.Stop ();
result += _sw.Elapsed.Milliseconds;
}
Debug.LogFormat ("action call: {0:F2}", result / (float) TESTS);
}
}

Observer

Третий вариант - использование паттерна Observer: сохранение инстансов классов с нужным методом вместо сохранения ссылки на сам метод:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
class ObserverTest : MonoBehaviour {
const int TESTS = 10;
const int T = 10000;
public static int Counter;

List<Observer> _observers = new List<Observer> ();

IEnumerator Start () {
yield return new WaitForSeconds (3f);
var result = 0;
var sw = new System.Diagnostics.Stopwatch ();
for (var i = 0; i < T; i++) {
_observers.Add (new Observer ());
}
for (var test = 0; test < TESTS; test++) {
sw.Reset ();
sw.Start ();
for (var i = 0; i < T; i++) {
for (int j = 0; j < _observers.Count; j++) {
_observers[j].Action ();
}
}
sw.Stop ();
result += _sw.Elapsed.Milliseconds;
}
Debug.LogFormat ("observer call: {0:F2}", result / (float) TESTS);
}

class Observer {
public void Action () {
ObserverTest.Counter = (ObserverTest.Counter + 1) % 10000;
}
}
}

InterfaceObserver

Четвертый вариант - как третий, но с использованием интерфейса:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
class InterfaceObserverTest : MonoBehaviour {
const int TESTS = 10;
const int T = 10000;
public static int Counter;

List<IObserver> _interfaces = new List<IObserver> ();

IEnumerator Start () {
yield return new WaitForSeconds (3f);
var result = 0;
var sw = new System.Diagnostics.Stopwatch ();
for (var i = 0; i < T; i++) {
_interfaces.Add (new VirtualObserver ());
}
for (var test = 0; test < TESTS; test++) {
sw.Reset ();
sw.Start ();
for (var i = 0; i < T; i++) {
for (int j = 0; j < _interfaces.Count; j++) {
_interfaces[j].Action ();
}
}
sw.Stop ();
result += _sw.Elapsed.Milliseconds;
}
Debug.LogFormat ("interface-observer call: {0:F2}", result / (float) TESTS);
}

interface IObserver {
void Action ();
}

class VirtualObserver : IObserver {
public void Action () {
InterfaceObserverTest.Counter = (InterfaceObserverTest.Counter + 1) % 10000;
}
}
}

Результаты

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// unity 2017.3.0f3, fw3.5 profile, 10k iterations, average of 10 tests.

// standalone macos 10.13.2, mono.
event call: 752.10
action call: 720.10
observer call: 716.40
interface-observer call: 724.50

// samsung N9005, mono.
event call: 599.60
action call: 271.30
observer call: 628.90
interface-observer call: 427.60

// samsung N9005, il2cpp.
event call: 815.60
action call: 313.70
observer call: 767.50
interface-observer call: 405.70

// iPhone6s iOS 11.2.2, il2cpp, fast-no-exceptions.
event call: 804.10
action call: 626.60
observer call: 62.00
interface-observer call: 490.10

// iPadPro 13" iOS 11.2.2, il2cpp, fast-no-exceptions.
event call: 465.80
action call: 316.00
observer call: 862.60
interface-observer call: 186.10

Очень странные результаты на iPhone6s / iPadPro13”: Observer-вариант на первом устройствезначительно быстрее, чем InterfaceObserver-вариант, на втором же - наоборот.

Остальные результаты достаточно стабильны на всех устройствах: ActionList-вариант быстрее или сравним с другими вариантами.

Event-вариант самый медленный во всех тестах, но поддерживает очень важный механизм - мы можем модифицировать коллекцию подписчиков прямо внутри обработки события без получения ошибок, все остальные варианты требуют дополнительной работы над изоляцией итераторов (или копированием данных во временный буфер на момент вызова).

Оформить подписку можно здесь: