Компания
84,99
рейтинг
7 января 2015 в 22:52

Разработка → Продолжаем кромсать 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();
        }
    }
}


Вывод в консоль:
Автор: @sidristij
Luxoft
рейтинг 84,99

Комментарии (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
    При каких условиях ваш код перестанет работать?

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

Самое читаемое Разработка