《Unity3D高级编程之进阶主程》 第一章,程序逻辑优化技巧

我们常常说起优化,总是不免想到渲染,Drawcall,Overdraw,引擎算法,底层组件,以及大型的整套的解决方案等,却很少关注普通业务逻辑中问题,其实不只是算法能大幅度提高业务逻辑的效率,普普通通的业务代码也同样可以有质的飞越。

一行普通的代码,高手写和普通人写可能就会不一样,优秀程序员常关注代码对性能的影响,普通人只关注是否能完成功能,这样日积月累代码质量就会形成差距。这节我们就来讲讲,在我们平时的业务逻辑编程中,我们应该怎么去优化这些不起眼但却蕴含着巨大潜力的性能问题。

使用List和Dictionary时提高效率

前面几节我们讲解了List、Dictionary的源码,知道了它的实质都是数组,Dictionary有两个数组,一个数组存索引一个数组存数据,我们遍历List和Dictionary都是在遍历数组。

了解底层的逻辑有助于我们更好的运用的它们,比如当我们使用List插入时,我们知道其实就是向数组中写入元素并遍历其后面的数据依次向后移动的过程。了解了这些,每次当我们使用List的Insert时都会注意一些。还有Contains函数,它是一个以遍历形式来寻找结果的函数,每次使用它都会从头到尾遍历一下直到寻找到结果,Remove也是一样,都是以遍历的形式存在。如果我们在代码中使用它们的频率比较高,就会带来很多不必要的性能消耗,这是我们很多人常常不注意的。我们常常因为不了解或图方便而使用了这些接口而并没有考虑它们带来的性能损耗。

Dictionary也有诸多问题,首先它是一个使用哈希冲突解决关键字的字典组件,因此哈希值与容器中数组的映射关键和GetHashCode获取哈希值的函数比较关键。哈希冲突与数组大小有很大关系,数组越大哈希冲突率就越小,因此我们在写程序时应该注意设置Dictionary的初始大小,尽量设置一个合理的大小,而不是什么不做,任由它自己扩容,这不但让哈希冲突变得频繁,而且扩容时数组的回收也加重了GC的负担。除了之外,在C#中所有类都继承自Object类,Dictionary使用Object的GetHashCode来获取类实例的哈希值,而GetHashCode是用算法将内存地址转化为哈希值的过程,因此我们可以认为它只是一个算法,并没有做任何值做缓存,每次调用它都会计算一次哈希值,这是比较隐形的性能损耗。如果频繁使用GetHasCode作为关键字来提取Value,那么我们应该关注下GetHashCode的算力损耗,是否可以用唯一ID的方式来代替GetHashCode算法。

巧用Struct

Struct和Class的区别常常被人遗忘,Struct结构是值类型,它与Class不同的是Struct传递时并不是靠引用(指针)形式来传递而是靠复制,我们可以通俗的认为它是通过内存拷贝来实现传递(真实的情况是通过字节对齐规则循环多次复制内存),也就是说我们在传递Struct时,其实是在不断的克隆数据。

struct A
{
	public int gold;
}

public void main()
{
	A a = new A();
	a.gold = 1;
	A b = a;
	b.gold = 2;
}

我们来简单举个例子,上述Struct中有一个整数变量gold,实例a的gold值为1,将a赋值给b后,b的gold设置为2,此时a中的gold依然为1,因为a和b是两份不同的内存。

Struct这样值类型对我们做性能优化有什么好处呢?首先如果Struct被定义于函数中的局部变量,则Struct的值类型变量分配的内存是在栈上的,栈是连续内存,并且在函数调用结束后,栈的回收非常快速和简单只要将尾指针置零就可以了(并非真正意义上的释放内存),这样我们即不会产生内存碎片,也不需要内存垃圾回收,CPU读取数据对连续内存也非常友好高效。

除了上述这些,Struct数组也对我们提高内存访问速度有所帮助。我们要明白由于Struct是值类型,所以它的内存也与值类型一样是连续的,Class数组则只是引用(指针)变量空间连续,这是大不同的。连续内存在CPU读取数据时,CPU的缓存可以帮助我们提高命中率,因为CPU在读取内存时会把一个大块内容放入缓存,当下次读取时先从缓存中找如果命中则不需要再向内存读取数据(缓存比内存快100倍),而非连续内存则在缓存使用时的命中率比较低,因此CPU缓存命中率的高低很影响CPU效率。

但是也不是所有Struct都能提高缓存命中率,如果Struct太大超过了缓存拷贝的数据块,则缓存就不再起作用了,因为拷贝进去的数据只有1个甚至半个Struct。于是就有很多架构就抛弃了Struct,彻底使用值类型连续空间的方式来提高CPU缓存命中率,把所有的数值都集合起来用数组的形式存放,而具体对象上则只存放一个索引值,当需要存取时都通过索引来操作数组。我们来看一个例子就知道是怎么回事。

class A
{
	public int a;
	public float b;
	public bool c;
}

class B
{
	public int index;
}

class C
{
	private static C _instance;
	public static C instance
	{
		get
		{
			if(null == _instance)
			{
				_instance = new C();
				return _instance;
			}
			return _instance;
		}
	}

	public int[] a = new int{2, 3, 5, 6};
	public float[] b = new float{2.1f, 3.4f, 1.5f, 5.4f};
	public bool[] c = new bool{false, true, false, true};
}

public void main()
{
	A[] arrayA = new A[3]{new A(), new A(), new A()};

	print("A class this is a {0} b {1} c {3}",Aa.a, Aa.ba, Aa.c);
	
	B b = new B()
	b.index = 2;

	C c = C.instance;

	print("B class this is a {0} b {1} c {3}",c.a[b.index], c.b[b.index], c.c[b.index]);
}

上述代码中A类使用了我们非常熟悉的面向对象的编程方式,把所有属性变量都放在自己身上,数据的集合则以引用的方式存储在数组上,而B则将数据集中存储在了C类中。当两者都对数据进行存取时,A类数据的内存是分散的,因为每次分配A实例时都是从内存中找一块空地来分配并不保证相邻,arrayA中只是引用连续而非内存连续,而B类的数据是连续内存的数组,因为它将所有同类的数据集中在了值类型的数组中,值类型的数组分配内存是一定是内存连续的,这样就能更多的利用缓存提高CPU读取数据的命中率。缓存机制是将最近使用过的数据存入最近的最快的空间中,离CPU最近的就是一级和二级缓存,它们是珍贵的,我们应该充分利用他们。

尽可能的使用对象池

说到类实例,我们应该清楚的明白,内存分配和消耗对我们程序的影响,这也是提高程序效率的关键所在。Unity3D使用的C#语言,它使用垃圾回收机制回收内存,即使Unity3D在发布后将C#转为了C++也依然使用垃圾回收机制来执行分配和销毁内存。作为高级程序员,我们应该要感受到,在创建类实例时内存分配的CPU耗时,以及垃圾回收时的艰难。

垃圾回收有多艰难呢?我们来解释一下,我们在C#中随意的new创建类实例,由不管它们的死活所以的丢弃或置空引用变量,类实例不断的被引用和间接引用,又不断被抛弃,垃圾回收器就要负责仔仔细细的收拾我们的烂摊子。内存不可能永远被分配而不回收,于是垃圾回收只能在当内存不够用的时候去到处问和检查(意思是遍历所有已分配的内存块),看看哪个类实例完全被遗弃了就捡回来(意思是完全没有被人引用了),将内存回收掉。因此当我们业务逻辑越庞大数据量越多时,垃圾回收需要检查的内容也越来越多,如果回收后依然内存不足,就得向系统请求分配更多内存。

垃圾回收过程如此艰难,它每次回收时都会占用大量CPU算力,因此我们应该尽可能的用对象池来重复利用已经创建的对象,这有助于减少内存配合时的耗时,也减少堆内存的内存块数量,最终减少了垃圾回收时带来的CPU损耗。

除了new 某个类创建内存导致的GC耗时增加外,以我的经验来看,很容易被我们忽略的是 new List 这种类型的使用,我们在平时编程时会大量使用动态数组,并且随时将它抛弃,因为它太容易使用以及丢弃了。类似的 Dictionary<int,List> 也是众多被忽略的内存分配消耗之一,被装进Dictionary字典中的List常被随意的丢弃(Remove掉),没去注意它是否能被再次利用。

C#中一个简单的通用对象池就能解决问题,但我们常常嫌弃它,并觉得麻烦。在我的编程经验中,图方便图好用的往往要付出性能损耗的代价,而性能高的代码通常都有点反人性,我们应该尽量找到一个平衡点,即有高的代码可读性,也尽量不要被人性所驱使了去做一些图方便的事,这在任何时候都是非常有用的价值的。

internal class ObjectPool<T> where T : new()
{
    private readonly Stack<T> m_Stack = new Stack<T>();
    private readonly UnityAction<T> m_ActionOnGet;
    private readonly UnityAction<T> m_ActionOnRelease;

    public int countAll { get; private set; }
    public int countActive { get { return countAll - countInactive; } }
    public int countInactive { get { return m_Stack.Count; } }

    public ObjectPool(UnityAction<T> actionOnGet, UnityAction<T> actionOnRelease)
    {
        m_ActionOnGet = actionOnGet;
        m_ActionOnRelease = actionOnRelease;
    }

    public T Get()
    {
        T element;
        if (m_Stack.Count == 0)
        {
            element = new T();
            countAll++;
        }
        else
        {
            element = m_Stack.Pop();
        }
        if (m_ActionOnGet != null)
            m_ActionOnGet(element);
        return element;
    }

    public void Release(T element)
    {
        if (m_Stack.Count > 0 && ReferenceEquals(m_Stack.Peek(), element))
            Debug.LogError("Internal error. Trying to destroy object that is already released to pool.");
        if (m_ActionOnRelease != null)
            m_ActionOnRelease(element);
        m_Stack.Push(element);
    }
}

internal static class ListPool<T>
{
    // Object pool to avoid allocations.
    private static readonly ObjectPool<List<T>> s_ListPool = new ObjectPool<List<T>>(null, l => l.Clear());

    public static List<T> Get()
    {
        return s_ListPool.Get();
    }

    public static void Release(List<T> toRelease)
    {
        s_ListPool.Release(toRelease);
    }
}

这两个对象池的类都是从Unity的UI库中提取出来的,都是非常实用的对象池工具,我们应该尽可能的使用他们。上述对象池使用了栈队列,将废弃的对象存储起来,并在需要时从栈队列中推出实例交给使用者。对象池并不复杂,麻烦的是使用,程序中对所有需要创建对象实例、销毁对象实例、移除对象实例的部分都需要用对象池去调用。

我们来举几个使用上述ObjectPool和ListPool对象池的例子:

public class A
{
	public int a;
	public float b;
}



public void Main()
{
	Dictionary<int,A> dic2 = new Dictionary<int, A>(16);
	for(int i = 0 ; i<1000 ; i++)
	{
		A a = ObjectPool<A>.Get(); //从对象池中获取对象
		a.a = i;
		a.b = 3.5f;

		A item = null;
		if(dic.TryGetValue(a.a, out item))
		{
			ObjectPool<A>.Release(item); //值会被覆盖,所以覆盖前收回对象
		}

		dic[a.a] = a;

		int removeKey = Random.RangeInt(0,10);
		if(dic.TryGetValue(removeKey, out item))
		{
			ObjectPool<A>.Release(item); //移除时收回对象
			dic.Remove(removeKey);
		}
	}

	Dictionary<int,List<A>> dic2 = new Dictionary<int, List<A>>(1000);
	for(int i = 0 ; i<1000 ; i++)
	{
		List<A> arrayA = ListPool<A>.Get(); // 从对象池中分配List内存空间

		dic2.Add(i,arrayA);

		List<A> item = null;
		int removeKey = Random.RangeInt(0,1000);
		if(dic.TryGetValue(removeKey, out item))
		{
			ListPool<A>.Release(item); // 移除时收回对象
			dic.Remove(removeKey);
		}
	}

}

上述代码中,A类和List需要被创建1000次,每次创建都使用对象池,并在字典Dictionary移除时将对象送回对象池。这样我们就可以不断利用被回收的对象池,然不用总是创建新的对象,所有被遗弃的对象都会被存储起来,并不会被垃圾回收程序回收,最终内存不断被重复利用,减少内存分配和释放所带来的消耗。

字符串导致的性能问题

说实在字符串性能在大部分语言中都是比较难解决的问题,C#中尤其如此。在C#中string是引用类型,每次我们动态创建一个string时C#都会在堆内存中分配一个内存用于存放字符串。我们来看看它到底有多恐怖。

string strA = "test";
for(int i = 0 ; i<100 ; i++)
{
	string strB = strA + i.ToString();

	string[] strC = strB.Split('e');

	strB = strB + strC[0];

	string strD = string.Format("Hello {0}, this is {1} and {2}.",strB, strC[0], strC[1]);
}

这是一段恐怖的程序,循环中每次都会将strA字符串和 i整数字符串连接,strB所得到的值是从内存中新分配的字符串,然后将strB切割成两半成为了strC,这两半又新分配了两段新的内存,再将strB与strC[0]连接起来,这又申请了一段内存,这段内存装上了strB和strC[0]两者连接的内容赋值给了strB,strB原来的内容因为没有变量指向就找不到了,最后用string.Format的形式将4个字符串串联起来,新分配的内存中装有4者的连接内容。

这里要注意一点,字符串常量是不会被丢弃的,比如这段程序中 “test” 和 “Hello {0}, this is {1} and {2}.” 这两个常量,它们常驻与于内存,即使下次没有变量指向它们,它们也不回被回收,下次使用时也不需要重新分配内存。原因我们放到下面计算机执行原理里去说。

每次循环中,都向内存申请了5次内存,并且抛弃了1次strA + i.ToString()的字符串内容,因为没有变量指向了这个字符串。这还不是最恐怖的,最恐怖的是,每次循环结束都会将前面所有分配的内存内容都抛弃,重新分配一次,就这样向不断抛弃和申请,总共向内存申请了500次内存段,并全部抛弃,内存被浪费的很厉害。

为什么会这样呢?究其原因是C#语言对字符串并没有任何缓存机制,每次使用都需要重新分配string内容,据我所知很多语言都没有字符串的缓存机制,因此我们的字符串连接、切割、组合等操作都会向内存申请新的内容,并且抛弃没有变量指向的字符串,等待GC回收。我们知道GC一次会消耗很多CPU,如果我们不注意字符串的问题,不断浪费内存将导致程序不定时卡顿,并且随着程序运行时间越来越长,各程序模块不良代码的运行积累,程序卡顿次数会逐步提高,运行效率也将越来越差。

解决字符串问题有两个方法,第一是自建缓存机制,可以用一些标志性的Key键值来一一对应字符串,比如游戏项目中常用ID来构造某个字符串,如下伪代码:

int ID = 101;

ResData resData = GetDataById(ID);

string strName = "This is " + resData.Name;

return strName;

一个ID变量对应一个字符串,这种形式,我们可以建立一个字典容器将它缓存起来,下次用的时候就不需要重新申请内存了,如下伪代码:

Dictionary<int,string> strCache;

string strName = null;
if(!strCache.TryGetValue(id, out strName))
{
	ResData resData = GetDataById(ID);
	string strName = "This is " + resData.Name;
	strCache.Add(id, strName);
}

return strName;

我们用Dictionary字典容器将字符串缓存起来,每次先查询字典中的内容是否存在,有则直接使用,没有则创建一个并将其植入字典容器中,以便下次使用。

第二中方式需要用C#到一些‘不安全’的native方法,也就是类似C++的指针方式来做处理string。

由于string类本身一定会申请新的内存,因此我们需要突破这个瓶颈,直接使用指针来改变string中字符串的值,这样就能重复利用string,不需要重新分配内存。

C#虽然委托了大部分内存内容,但它也允许我们使用非委托方式来访问和改变内存内容,这对C#来说是不安全的(C#中有unsafe关键字)。我们可以通过非委托方式来改变string中的内容,使它能够被我们再利用。我们看个例子,如下代码:


string strA = "aaa";

string strB = "bbb" + "b";

fixed(char* strA_ptr = strA)
{
	fixed(char* strB_ptr = strB)
	{
		memcopy((byte*)strB_ptr, (byte*)strA_ptr, 3*sizeof(char));
	}
}

print(strB); // 此时strB内容为 “aaab”

注意这里用”bbb” + “b”的方式生成新字符串,是因为我们不打算改变常量字符串内存块,所以新分配了内存来做实验。

我们把strB的前3个字符的内容变成了strA中的内容,但我们并没有增加任何内存,因为我们使用了不安全的非托管方法,自己来控制内存。通过这样的方式来再利用已经申请的string内存,将已经有的string缓存起来再利用。我们来看看如何再利用的例子。

Dictionary<int,string> cacheStr;

public unsafe string Concat(string strA, string strB)
{
	int a_length = a.Length;

	int b_length = b.Length;

	int sum_length = a_Length + b_Length;

	string strResult = null;

	if(!cacheStr.TryGetValue(sum_length, out strResult))
	{
		//如果不存在sum_length长度的缓存字符串,那么久直接连接后存入缓存。
		strResult = strA + strB;

		cacheStr.Add(sum_length, strResult);

		return strResult;
	}

	//将缓存字符串再利用,用指针方式直接改变它的内容
	fixed(char* strA_ptr = strA)
	{
		fixed(char* strB_ptr = strB)
		{
			fixed(char* strResult_ptr = strResult)
			{
				//将strA中内容拷贝到strResult中
				memcopy((byte*)strResult_ptr, (byte*)strA_ptr, a_length*sizeof(char));

				//将strB中内容拷贝到strResult的a_Length长度后面内存中
				memcopy((byte*)strResult_ptr+a_Length, (byte*)strB_ptr, b_length*sizeof(char));
			}
		}
	}

	return strResult;
}

当我们需要将连个字符串连接起来时,先去看看缓存中是否有可用长度的字符串,如果没有就直接连接并缓存,如果有则取出来,用针的方式改变缓存字符串的值。其中memcopy并不是系统函数而需要自己写,写法也很简单,拿到两个指针根据长度便利指针便利并赋值。如下可见:

public unsafe void memcopy(byte* dest, byte* src, int len)
{
	while((--len)>=0)
	{
		dest[len] = src[len];
	}
}
字符串的隐藏问题。

string的Length方法可不是省油的灯,由于string类中只存放了字符串char[]数据,没有其他变量,因此string.Length需要通过遍历字符串来获取长度。类似于如下伪代码:

int sum = 0;
for( ; data[sum] != '\0' ; ++sum);
return sum;

如果我们大量使用Length 就会浪费很多CPU,因为每次它都会遍历整个字符串。特别是是循环中的判断,如下伪代码:

string str = "Hello world.";
for(int i = 0 ; i<str.Length ; ++i)
{
	// do some thing.
}

上述代码就相当于两层循环,每次判断时都会重新遍历一遍字符串以获得长度。

字符串比较也有隐藏问题,当两个字符串比较时,string首先会比较两个字符串的指针是否是一致的,一致则返回true,如果指针不一致则会遍历两者判断是否每个字符都是相等的。我们来看看它究竟是怎么做的:

public bool Equals(String value) {
    if (this == null)                        //this is necessary to guard against reverse-pinvokes and
        throw new NullReferenceException();  //other callers who do not use the callvirt instruction

    if (value == null)
        return false;

    if (Object.ReferenceEquals(this, value))
        return true;
    
    if (this.Length != value.Length)
        return false;

    return EqualsHelper(this, value); //遍历两者的字符
}

这段代码从源码中提取,在判空后,先判定两个string的引用是否相等,如果不相等再判断两者的长度是否相等,如果相等,再去遍历字符串的每个字符,每个字符是否相等,最终判定字符串是否相等。

倘若我们操作后的两个字符串来自不同的内存段,那么它们在比较是否相等时就会遍历所有字符来判定是否相等。如下伪代码:

string strA = "Hello ";

string strB = "Hello ";

strA = strA + "C";

strB = strB + "C";

if(Object.ReferenceEquals(strA, strB))
{
	return true;
}

if(strA == strB)
{
	return false;
}

return true;

这段代码最终会返回false,strA与strB看似相等,实则为不同内存段内容,当它们两使用等号比较时,就会遍历所有字符串里的字符来确定它们是否相等。

string源码地址:https://referencesource.microsoft.com/#mscorlib/system/string.cs

程序运行原理

为了能更好的优化我们写的程序,我们应该再深入些底层来了解下,计算机是如何执行我们写的程序的。这次我想我们得脱离下c#语言,因为c#为我们包装了太多东西,我们用起来很方便,同时也蒙蔽了我们的眼睛,让我们看不清底层原理。

计算体系结构比较复杂也脱离了我们的主题内容,这里我想简单陈述下程序运行的原理,从而让我们可以擦亮眼睛看清一些我们被蒙蔽的原理。

最终计算机要的都是机器码,那么机器码是怎么产生的呢,是通过编译产生的,机器码太难记,汇编就是用来帮助我们记机器码的,每个汇编指令都对应一个机器指令,所有由‘1’和‘0’组成的机器指令码都能一一对应到汇编指令,这么来看,一个可执行文件或一个库文件通常可以转化成汇编代码,很多黑客也是通过这种方式来查看我们写的程序的,厉害的黑客他们通常都精通汇编。

一个程序在内存中运行时,通常由几个内存块组成,其中一个是指令内存块,里面存储的都是已经编写设计好的执行的指令,需要执行的指令都会从指令内存块中去取,指令计数器也不断跳跃在这些指令中。另一个为数据内存,里面存放的都是我们设置好的数据以及分配过的内存,其数据块中有一块内容可以称为静态数据块,通常里面存放的都是不变的数据,比如字符串常量,常量整数,常量浮点数,以及一些静态数据,这些数据在程序启动时最先被放入内存中。

数据内存中,除了静态内存数据块外就是我们所说的堆内存数据。我们所有的动态内存申请都来自堆内存,我们可以认为它是一个很长的byte数组,当我们申请内存时,会从数组中找出一块我们指定大小的内存,这个内存不一定是空的,因为内存回收从来不会对内存单位有清理操作,那样太浪费算力了,从来都是将这段数据的指针回收或偏移,所以实际上,我们申请的内存块,在没初始化前都是未知的,有可能刚好前面用过的与我们相似的内容,如果不进行初始化直接使用就有可能会出现逻辑问题,例如本以为bool数值是false却得到的true。

我们用惯类对象很容易以为内存中就是某个类的实例内存,其实在机器指令和内存中可没有这个说法,它只是块连续byte内存单元,具体其代表哪个类的实例只是我们想象的而已,这些都是编译器的功劳,这让我们不需要取关心某个内存块到底指的是什么,我们只需要知道程序里类是怎么写就可以了,高级语言让我们更方便,也让我们更傻瓜。其中最容易混淆的就是类的方法,很多人认为它也被放入了对象实例中,其实并不是这样,它被编译成指令序列,放在了指令内存块中,所有的方法,函数都在那里集中存放着。

因此一个可执行文件或程序库里,几乎都是指令机器码,以及指令附带的常量数据,如果常量比较多可执行文件也跟着会变大。我们运行这段程序时,可执行文件和库被装入内存成为指令段内存,里面装着所有类的方法和函数,包括静态、公共的、私有的,都只是名字上的不同,比如 Class_A_public_GetData 可以认为是类对象A的public方法的GetData,这些字符串也通常代表指令的地址。

除了上述这些,栈内存块也是比较重要的内容,它通常都是函数方法执行的重要部分,它与堆内存不同的是它是有秩序的,只允许遵守先进后出的规则,每分配一块内存,回收时也必须按照先进后出的秩序回收,这个规则使得栈内存永远是连续的,不会因为很多次使用后变出很多内存碎片从而导致有内存而无法分配的现象。我们所说的值类型数据大都在栈中分配,除非它被用于构造其他类型,比如类、数组等。

说了这么多,其实就是汇编里的数据段、代码段、栈段三个段,他们分别使用了段地址和偏移量来表示数据和指令内容。当指令数据需要数据段内容时,就用数据段地址+偏移量去存取数据内存中的数据,当指令跳转时则使用代码段地址+偏移量来指向新的指令内存地址,当需要用到栈时则使用pop和push的汇编指令来偏移栈顶指针从而存取栈上的数据。除了内存,寄存器是离cpu最近也最快的存储单元,它一般都用来临时存放数据的,当然我们也可以自己写汇编让某些寄存器长期存放些数据以加快读取某数据的速度。

· 书籍著作, Unity3D, 前端技术

感谢您的耐心阅读

Thanks for your reading

  • 版权申明

    本文为博主原创文章,未经允许不得转载:

    《Unity3D高级编程之进阶主程》 第一章,程序逻辑优化技巧

    Copyright attention

    Please don't reprint without authorize.

  • 微信公众号,文章同步推送,致力于分享一个资深程序员在北上广深拼搏中对世界的理解

    QQ交流群: 777859752 (高级程序书友会)