Comments 23
Замечания:
1. Тестить в самом редакторе нельзя (код всегда собирается в DEBUG-режиме), обязательно нужно собирать standalone-билд и замерять в нем.
2. Нельзя просто покрутить цикл N-раз и взять результаты. Нужно запустить цикл M-раз и усреднить — это сгладит различные сайд-эффекты типа изменения частоты процессора и прочих вещей.
3. Эвенты не равняются прямым вызовам — внутри есть проверки + защита списка вызовов от изменения. Т.е мы можем изменить список подписчиков на событие прямо в процессе самого вызова события и это ничего не поломает. Другое дело, что каждая подписка / отписка вызывает memory allocation, поэтому эвенты желательно обрабатывать своим списком с циклом по нему.
Как пример, можно посмотреть тесты отсюда.
По пункту 3 не так, внутри ивента(на самом деле делегата) нет защиты списка вызовов от изменений, просто делегат это иммутабельный тип — каждое изменение порождает копию.
Даже не так. Эвент, это обертка над списком, потому что сам эвент может принимать новых подписчиков и создавать копию старого списка с изменениями. Этот список будет использоваться при последующих вызовах, создавая видимость иммутабельности.
Нет, не так. Ивент, по-умолчанию, это обертка над мультикаст делегатом, а мультикаст делегат внутри использует массив, а не список(см. referencesource.microsoft.com/#mscorlib/system/multicastdelegate.cs).
Нет никакой иллюзии иммутабельности, а есть настоящая неизменяемость — делегаты не могут быть изменены после создания, любые операции надо ними приводят к созданию копии массива(cм., например, реализацию сложения делегатов: referencesource.microsoft.com/#mscorlib/system/multicastdelegate.cs,fcbf8bdc05d28aeb,references)

внутри использует массив, а не список

«Список» — имелось ввиду хранилище, а не конкретная реализация в виде List. К тому же ссылка некорректна, в юнити не используется последняя версия .net framework. :)

любые операции надо ними приводят к созданию копии массива

А я что написал? Снаружи остается тот же самый объект-обертка, внутри создается новая копия хранилища подписчиков на основе текущей + изменения в подписке.
Объект не тот же самый, объект новый. Ссылка корректна моновская имплементация совпадает с референсной в большинстве случаев, а это базовая вещь для c# — делегаты в нем неизменяемые объекты.
Но если очень хочется вот ссылка на тот же файл в моно:
github.com/mono/mono/blob/master/mcs/class/corlib/System/MulticastDelegate.cs
Объект не тот же самый, объект новый.

Что?
event Action OnTest = delegate {};
...
OnTest += () => {};
OnTest += () => {};

И у меня теперь OnTest не указывает на тот же самый объект, содержащий внутри себя массив на 3 подписчика?
Объект который лежит под OnTest(сам OnTest это не объект это синтаксический сахар, в лучшем случае поле класса), указывает на объект содержащий 3 подписчика, но это не тот же самый объект что лежал под OnTest до добавления в него первого делегата
Вот убедительно показывающий неизменяемость делегата пример:
using System;
namespace ConsoleApplication1
{
	class Program
	{
		static void Main(string[] args)
		{
			Action a = () => { Console.WriteLine("1"); };
			Action b = a;
			a += () => { Console.WriteLine("2"); };

			Console.WriteLine("A:");
			a();
			Console.WriteLine("B:");
			b();
		}
	}
}

Вывод:
A:
1
2
B:
1
Все верно, сам затупил:
OnTest += ...

Присваивание нового инстанса же.
Вот пример ещё лучше конкретно про ивенты, это иллюстрация ошибки, которую я видел от опытного программиста в реальном проекте.
	public class B
	{
		public event Action evt = () => { };

		public void Call()
		{
			evt();
		}
		
	}

	public class A
	{
		private B _b;
		private Action a = () => { };
		public A(B b)
		{
			_b = b;
			_b.evt += a;
		}

		
		public event Action evt
		{
			add { a += value; }
			remove { a -= value; }
		}
	}

	class Program
	{
		static void Main(string[] args)
		{
			B b = new B();
			A a = new A(b);

			a.evt += () => Console.WriteLine("1");
			b.evt += () => Console.WriteLine("2");
			b.Call();
		}
	}

Какой по вашему будет вывод?
Скрытый текст
2
Потому что объект под ивентом иммутабельный

Тогда еще и способ подсчета времени следует поменять — точность Time.realtimeSinceStartup никуда не годится.
Я же кинул линк (первый комментарий), там есть пример: leopotam.com/3
Test environment

We will run 10000 iterations and will repeat this process 10 times, then will take average result time. We will measure time for calling our callbacks for one event at each implementation. As we will do this inside Unity we should knows that:

We can’t use Time.realtimeSinceStartup for performance measurements — accuracy is very low. Instead we will use standard System.Diagnostics.Stopwatch class.
We can’t start measure right on game start — Unity needs time to full initialization, we should wait few seconds when hardware resources will be freed. 3 seconds — enough for this test.

Editor always compiles and runs code in DEBUG mode. For proper measure we need RELEASE version — we should creates standalone build and makes measurements with this build outside Unity editor. We will use Debug.Log method for save results to external logs.

Спасибо, изучу это и внесу изменения в соответствии с новыми данными

Не хватает теста явного хранения списка подписчиков реализующих общий интерфейс. Вызовы виртуальных функций должны быть дешевле ивентов/делегатов.

Можно какой нибудь пример реализации? Не уверен, что правильно понимаю о чем речь, но интересно протестировать

Я имею ввиду простую реализацию паттерна observer, супер простой пример вот:
Скрытый текст
public interface INaiveObservable<T>
	{
		void Subscribe(INaiveObserver<T> observer);
		void Unsubscribe(INaiveObserver<T> observer);
	}

	public interface INaiveObserver<T>
	{
		void OnValue(T value);
	}

	public class NaiveObservable<T>: INaiveObservable<T>
	{
		readonly List<INaiveObserver<T>> _observers = new List<INaiveObserver<T>>();
		public void Subscribe(INaiveObserver<T> observer)
		{
			_observers.Add(observer);
		}

		public void Unsubscribe(INaiveObserver<T> observer)
		{
			_observers.Remove(observer);
		}

		public void Push(T value)
		{
			//No exception handling
			//No Subsribe/unsubscribe inside subscribers
			//This is a naive implementation
			foreach (var observer in _observers)
			{
				observer.OnValue(value);
			}
		}
	}

	public class WritingObserver<T> : INaiveObserver<T>
	{
		void INaiveObserver<T>.OnValue(T value)
		{
			Console.WriteLine(value);
		}
	}


	class Program
	{
		static void Main(string[] args)
		{
			NaiveObservable<int> observable = new NaiveObservable<int>();
			WritingObserver<int> observer = new WritingObserver<int>();
			observable.Subscribe(observer);
			observable.Push(10);
			observable.Push(11);
			observable.Push(12);
		}
	}

Спасибо за статью. Рельно удивлен что реализация через делегат настолько быстрее чем Event System.

Итак, данные в консоли и мы видим интересную картину — функция на получателе отработала в ~2,7 раза быстрее чем на отправителе.
Я так и не понял с чем это связано. Может в том, что на получателе после расчета времени дополнительно вызывается Debug.Log или в чем то другом… Если кто знает, то напишите мне и я внесу это в статью.

Вывод в лог — жутко прожорливая операция, не удивлюсь если основную разницу дает именно вывод в лог.

Спасибо за статью.
Про Debug.Log он действительно очень тормозит.
Можно попробовать без него или посмотреть профайлером.

Что касательно SendMessage, единственное ее преимущество — универсальность, она отправляется на GameObject и отправляет сообщение всем компонентам в объекте, у кого есть соответствующий метод, тот и выполняет. На сколько знаю сейчас используется в основном в плагинах что бы передавать сообщения на пользовательские скрипты при необходимости.
Only those users with full accounts are able to leave comments. Log in, please.