Тесты производительности 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-вариант самый медленный во всех тестах, но поддерживает очень важный механизм - мы можем модифицировать коллекцию подписчиков прямо внутри обработки события без получения ошибок, все остальные варианты требуют дополнительной работы над изоляцией итераторов (или копированием данных во временный буфер на момент вызова).