Продолжаем кромсать CLR: пул объектов .Net вне куч SOH/LOH

    Добрый день, уважаемые разработчики (просто не знал, с чего начать пост). Предлагаю перед тем как начнется трудовая неделя немного подразмять мозги (совсем немного) и построить свой Small Objects Heap для .Net. Вернее даже не Small Objects Heap, а Custom Objects Heap.

    Как все мы знаем, в .Net существует две группы куч: для больших и малых объектов. Как выяснить, во сколько нам обойдется объект можно при помощи кода из этой статьи (он нам пригодится): Ручное клонирование потока, а получить указатель на объект и по указателю получить сам объект можно научиться, прочтя эту статью: Получение указателя на объект .Net. Также нам понадобится статья корейского (южно-) программиста по перенаправлению указателя на скомпилированную часть метода на другой метод: 실행 시에 메서드 가로채기 — CLR Injection: Runtime Method Replacer 개선

    Так что давайте поэкспериментируем и напишем библиотеку, которая позволит:
    • Аллоцировать участок памяти
    • Разметить его как набор объектов определенного .Net типа
    • Выделять объекты с этой памяти
    • Возвращать их обратно


    Ссылка на проект на GitHub: DotNetEx


    Т.е. напишем пул объектов вне .Net памяти.
    Будем решать задачи по мере поступления вопросов.
    • Как разметить уже саллоцированный участок памяти каким-либо объектом?
      В любом объектно-ориентированном языке объект состоит из указателя на таблицу виртуальных методов и полей объекта. Таблица эта необходима чтобы понимать какие из перегрузок методов должны быть вызваны и сама по себе нам не так интересна в рамках данной задачи. Если мы работаем с каким-либо объектом и вызываем «у него» метод, это значит что сначала будет загружен адрес объекта, по нему будем расположен адрес таблицы виртуальных методов. А по этому адресу мы берем указатель на нужный метод и вызываем его. Значит чтобы любой участок памяти представить как объект определенного типа необходимо просто записать указатель на таблицу виртуальных методов.

      Как мы это можем сделать? Сначала я брал экземпляр существующего объекта, и вычитывал первые 4 или 8 байт, записывая их к себе. Метод работал, но он не красивый. Нужен экземпляр. После чего я нашел что этот адрес легко вычитывается с помощью свойства typeof(TType).TypeHandle.
    • Как выделить кусок памяти?
      Это сделать совсем просто: есть функцияMarshal.AllocHGlobal(_totalSize), которая позволяет выделить любое требуемое количество виртуальной памяти. Если вам при этом надо разместить эту память по любому адресу, то надо воспользоваться ее WinApi аналогом.
    • А как же вызов конструктора?
      Для того чтобы вызвать конструктор, у нас три пути:
      • Сделать метод Init и вызывать его. Это не очень красиво, не очень спортивно. Однако, не надо лезть в рефлексию и во внутренности .Net CLR.
      • Вызывать конструктор через рефлексию. Более спортивный метод, однако рефлексия налагает определенные тормоза.
      • После редактирования таблицы скомпилированных тел методов вызвать другой метод, но при этом будет вызван конструктор. Это мозговыносящий метод и в нем – наибольший процент спорта =) Им и воспользуемся. Ведь получится прямой вызов, без посредников. Как будто конструктор и вызвали.


    Ну что, готовы? Давайте приступим.

    Первое, что мы определяем – типы

    internal static class Stub
    {
    	public static void Construct(object obj, int value)
    	{
    	}	
    }
    

    Это — тип, единственный статический метод которого будет изменен так, что указатель на скомпилированную часть метода будет смотреть на конструктор нашего типа. Первый параметр — obj — является по своей сути указателем this. Ведь, как вы знаете, this есть ни что иное как первый параметр метода, который есть всегда в каждом экземплярном методе. Второй параметр — целое число. Введен для того чтобы проверить что мы можем передавать целые числа.

    public class UnmanagedObject<T> : IDisposable where T : UnmanagedObject<T>
    {	
    	internal IUnmanagedHeap<T> heap;
    	
    	#region IDisposable implementation
    	void IDisposable.Dispose()
    	{
    		heap.Free(this);
    	}
    	#endregion
    } 
    

    Далее введем тип UnmanagedObject чтобы во-первых ввести метод возврата объекта в пул Dispose(), а во-вторых архитектурно отделить все объекты, предназначенные для размещения вне CLR куч от стандартных. Единственное поле класса типа internal, чтобы его можно было задать извне, в пуле объектов.

    И последнее — класс самого пула.

    public unsafe class UnmanagedHeap<TPoolItem> : IUnmanagedHeap<TPoolItem> where TPoolItem : UnmanagedObject<TPoolItem>
    	{
            private readonly IntPtr *_freeObjects;
    		private readonly IntPtr *_allObjects;
    		private readonly int _totalSize, _capacity;
    		private int _freeSize;
    	    private readonly void *_startingPointer;
    		private readonly ConstructorInfo _ctor;
    		
    		public UnmanagedHeap(int capacity)
    		{
    			_freeSize = capacity;
    			
                // Getting type size and total pool size
    			var objectSize = GCEx.SizeOf<TPoolItem>();
    		    _capacity = capacity;
    			_totalSize = objectSize * capacity + capacity * IntPtr.Size * 2;
    			
    			_startingPointer = Marshal.AllocHGlobal(_totalSize).ToPointer();
                var mTable = (MethodTableInfo*)typeof(TPoolItem).TypeHandle.Value.ToInt32();
                _freeObjects = (IntPtr*)_startingPointer;
                _allObjects = (IntPtr*)((long)_startingPointer + IntPtr.Size * capacity);
                _startingPointer = (void*)((long)_startingPointer + 2 * IntPtr.Size * capacity); 
    			
    			var pFake = typeof(Stub).GetMethod("Construct", BindingFlags.Static|BindingFlags.Public);
    			var pCtor = _ctor = typeof(TPoolItem).GetConstructor(new []{typeof(int)});
    		
    			MethodUtil.ReplaceMethod(pCtor, pFake, skip: true);
    			
    			for(int i = 0; i < capacity; i++)
    			{
    				var handler =  (IntPtr *)((long)_startingPointer + (objectSize * i));
    			    handler[1] = (IntPtr)mTable;
    			    var obj = EntityPtr.ToInstance<object>((IntPtr)handler);
                   
    				var reference = (TPoolItem)obj;
    				reference.heap = this;
    
                    _allObjects[i] = (IntPtr)(handler + 1);
    			}			
    
                Reset();
    		}
    		
    		public int TotalSize
    		{
    			get {
    				return _totalSize;
    			}
    		}
    				
    		public TPoolItem Allocate()
    		{
    			_freeSize--;
    			var obj = _freeObjects[_freeSize];
    			Stub.Construct(obj, 123);			
    			return EntityPtr.ToInstanceWithOffset<TPoolItem>(obj);
    		}
    		
    		public void Free(TPoolItem obj)
    		{
    			_freeObjects[_freeSize] = EntityPtr.ToPointerWithOffset(obj);
    			_freeSize++;
    		}	
    		
    		public void Reset()
    		{
                WinApi.memcpy((IntPtr)_freeObjects, (IntPtr)_allObjects, _capacity * IntPtr.Size);
    			_freeSize = _capacity;
    		}
    
    		object IUnmanagedHeapBase.Allocate()
    		{
    			return this.Allocate();
    		}
    		
    		void IUnmanagedHeapBase.Free(object obj)
    		{
    			this.Free((TPoolItem)obj);
    		}
    
            public void Dispose()
            {
                Marshal.FreeHGlobal((IntPtr)_freeObjects);
            }
        }
    


    По порядку:
    В конструкторе класса мы сначала рассчитываем размер экземпляра типа. После чего умножаем на capacity, добавляем размер таблиц свободных/занятых слотов и получаем размер пула. Получив размер, аллоцируем пул в виртуальной памяти. После чего получаем описатели методов: конструктора типа и заглушки и у заглушки выставляем указатель на тело метода как тело конструктора:
    			var pFake = typeof(Stub).GetMethod("Construct", BindingFlags.Static|BindingFlags.Public);
    			var pCtor = _ctor = typeof(TPoolItem).GetConstructor(new []{typeof(int)});
    		
    			MethodUtil.ReplaceMethod(pCtor, pFake, skip: true);
    

    Последнее — в цикле, у каждого будущего объекта проставляем указатель на таблицу вирт методов, делаем кастинг в .Net тип и выставляем поле heap у только что проинициализированного объекта в наш пул.

    Отдельный интерес представляет метод Allocate:

    		public TPoolItem Allocate()
    		{
    			_freeSize--;
    			var obj = _freeObjects[_freeSize];
    			Stub.Construct(obj, 123);			
    			return EntityPtr.ToInstanceWithOffset<TPoolItem>(obj);
    		}
    
    

    В нем мы сначала из таблицы свободных объектов берем последний из них. После чего вызываем метод Construct класса Stub, тело которого на самом деле — наш конструктор класса элемента пула. Конструктору передаем число 123 как параметр.

    Использование


    Использование протестируем с помощью следующего кода
    using System;
    using System.Runtime.CLR;

    namespace UnmanagedPoolSample
    {
        class Program
        {
            /// <summary>
            /// Now cannot call default .ctor
            /// </summary>
            private class Quote : UnmanagedObject<Quote>
            {
                public Quote(int urr)
                {
                    Console.WriteLine("Hello from object .ctor");
                }
    
                public int GetCurrent()
                {
                    return 100;
                }
            }
    
            static void Main(string[] args)
            {
                using (var pool = new UnmanagedHeap<Quote>(1000))
                {
                    using (var quote = pool.Allocate())
                    {
                        Console.WriteLine("quote: {0}", quote.GetCurrent());
                    }
                }
    
                Console.ReadKey();
            }
        }
    }
    


    Вывод в консоль:
    Метки:
    Luxoft 92,69
    Компания
    Поделиться публикацией
    Похожие публикации
    Комментарии 15
    • +5
      Имхо, вы забыли ответить на главный вопрос — нафига? Все-таки смысл с# именно в управлении памятью системой…
      • +2
        Для фана, полагаю. ИМХО, практически единственная ситуация, где такое могло бы быть полезно на практике — это оптимизировать какой-то критичный локальный участок в уже существующем продукте. Да и то проще уже тогда на нативный код переписать, чем использовать такой ад, который может сломаться от чиха в любой момент…
        • 0
          Для скорости роста бороды, конечно)
          Чтобы чувствовать себя совсем крутым дядькой, который знает как оно там внутри на самом деле устроено.
          Кругозор, общая эрудиция и все такое…
          • 0
            Во-первых для фана. Второе — мне просто приятно знать, как что устроено. И вовсе не для «борды», как написал оратор выше. Вам же наверняка стало также интересно. Вот и мне было интересно, когда копался в этом =)
          • 0
            А по этому адресу мы берем указатель на нужный метод и вызываем его. Значит чтобы любой участок памяти представить как объект определенного типа необходимо просто записать указатель на таблицу виртуальных методов.
            Прямо-таки и необходимо? Это нужно только для виртуальных методов, обычные же методы вызываются напрямую, не задействуя таблицу виртуальных методов вообще никак.
            • 0
              Обычно для вызова методов используется callvirt. Где-то читал, что они этим сэкономили на явной проверке валидности указателя, так как callvirt проверяет и босает NullReference если надо
              • 0
                Если метод — не виртуальный, то callvirt будет преобразован в обычный call на этапе оптимизации, разве нет?
                • 0
                  Сам компилятор C# генерирует callvirt. Сомневаюсь, что они будут сами с собой спорить.

                  Вот объяснение этой штуки: blogs.msdn.com/b/ericgu/archive/2008/07/02/why-does-c-always-use-callvirt.aspx
                  • 0
                    А компилятор оптимизацией и не занимается, это задача JIT.
                    • 0
                      Оптимизацией занимается и компилятор, и JIT. Иначе не было бы разницы между Release и Debug. Раскручивание циклов, удаление мертвого кода, удаление неиспользуемых переменных и еще много чего выполняется именно на этапе трансляции из C# в IL
                  • 0
                    Нет. call для статических методов, остальные через callvirt. Выше верно сказали про проверку на null
                    • 0
                      кстати, для задротской оптимизации, если надо вызвать нестатический метод у класса и ничего с объектом больше не делать — лучше делать new Program().Foo(); чем var p = new Program(); p.Foo(); ибо в первом случае будет call, без virt. =)
              • 0
                Вы не пробовали создать делегат на конструктор?
                • +1
                  Охренеть, круто!
                  • 0
                    При каких условиях ваш код перестанет работать?

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

                    Самое читаемое