C++

游戏开发常见问题思考

C++常见问题(二)

Posted by Francis-wu on October 8, 2018

回顾复习一些常见的C++问题,以及一些没有仔细考虑过的地方。(任何问题欢迎邮件:admin@franciswu.top

1、不同的继承方式

​ 一般在开发过程中都用的public继承,突然问这个问题感觉有点不确定。其实比较简单,public < protected< private,具体效果如下图:

​ 有关访问权限:

  • public: 一个类的public成员变量、成员函数,可以通过类的成员函数、类的实例变量进行访问。
  • protected:一个类的protected成员变量、成员函数,无法通过类的实例变量进行访问。但是可以通过类的友元函数、友元类进行访问。
  • private:一个类的private成员变量、成员函数,无法通过类的实例变量进行访问。但是可以通过类的友元函数、友元类进行访问。

protected和private的区别体现在继承上面,子类中的函数可以访问父类的protected和public,但是不能访问private。

public在任何地方都能访问,protected只能在派生类中访问, private只能在友元中访问。

2、常见的GC方式

​ 一般的垃圾回收方式有三种:

a、标记清除方式

​ 标记清除(Mark and Sweep)是最早开发出来的GC算法(1960年)。它的原理非常简单: 首先从根开始将可能被引用的对象用递归的方式进行标记,然后将没有标记到的对象作为垃圾进行回收。

​ 初始状态:

​ 标记阶段:

​ 清除阶段:

​ 上述图片显示了标记清除算法的大致原理。 “初始状态“图中显示了随着程序的运行而分配出一些对象的状态,一个对象可以对其他的对象进行引用。

标记阶段“图中显示了GC开始执行,从根开始可以被引用的对象上进行“标记“。大多数情况下,这种标记是通过对象内部的标志(Flag)来实现的。于是,被标记的对象我们将它涂黑。

​ 紧接着被标记的对象所能引用的对象也会被打上标记。重复这一步骤就可以从根开始可能被间接引用到的对象全部打上标记。到此为止的操作即被称为——标记阶段(Mark phase)。标记阶段完成时,被标记的对象就是“存活“对象,反之为“死亡“对象

​ 标记清除算法的处理时间,是和存活对象数与对象总数的总和相关的。作为标记清除的变形,还有一种叫做标记压缩(Mark and Compat)的算法,它不是将被标记的对象清除,而是将他们不断压缩。

b、复制收集方式

​ 标记清除算法有一个缺点,就是在分配了大量对象,并且其中只有一小部分存活的情况下,所消耗的时间会大大超过必要的值,这是应为在清除阶段还需要对大量死亡对象进行扫描。

** 复制收集(Copy and Collection)则试图克服这一缺点。在这种算法中,会将从根开始被引用的对象复制到另外的空间中,然后,再将复制的对象所能够引用的对象用递归的方式不断复制下去**。

​ 初始状态(1)——旧空间:

​ 新空间的开辟(2)——新空间:

​ 复制对象(3):

​ 如上图: (1)部分是GC开始前的内存状态,者也同时代表着对象在内存中所占用的“旧空间“。 图(2)在旧空间以外开辟“新空间“并将可能从根被引用的对象复制到新空间中。 图(3)从已经复制的对象开始再将可以被引用的对象逐个复制到新空间当中……随着复制的进行,直到复制完成——最终“死亡“对象就留在了“旧空间“当中,接着将旧空间废弃掉,这样就可以将“死亡“对象所占用的空间一口气释放出来,而没有必要再次扫描“死亡“对象的必要。而等到下次GC操作是,这次所创建的“新空间“就成为了将来的“旧空间“了。

复制收集方式的过程相当于只存在于标记清除方式中的标记阶段由于清除阶段中需要对所有对象进行扫描,这样如果在存在大量对象,且其中大量对象已经为“死亡“对象的情况下必然会造成不必要的资源和性能上的开销。 ​ 而在复制收集方式中就不存在这样的开销。但是和标记相比,将对象复制一份的开销相对要大,因此在“存活“对象相对比例较高的情况下,反而不利。

​ 复制收集方式的另一个优点是:它具有局部性(Locality)。在复制收集过程中,会按照对象被引用的顺序将对象复制到新空间中。于是,关系较近的对象被放置在距离较近的内存空间中的可能性会提高,这样被称为局部性。局部性高的情况下,内存缓存会更容易有效运作,程序的运行也能够得到提高。

c、 引用计数方式

​ 它的原理是:在每个对象中保存该对象的引用计数,当引用发生增减时对计数进行更新。 ​ 引用计数的增减,一般发生在变量复制,对象内容更新,函数结束(局部变量不在被引用),等时间点。当一个对象的引用计数为0时,则说明它将来不会再被引用,因此可以释放相应的内存空间

优点:

  • 相比标记清除复制收集方式实现更容易。

  • 当对象不再被引用的瞬间就会被释放。

  • 其他GC机制中,要预测一个对象何时会被释放是很困难的,而在引用计数方式中则是立即被释放。

  • 由于释放操作是针对个别执行的,因此和其他算法相比,由GC而产生的中断时间就比较短。

    缺点:

  • 无法释放循环引用的对象。如上图A,B,C三个对象没有被其他对象引用,而是互相之间循环引用,因此他们的计数永远不会为0,结果这些对象就永远不会被释放。

  • 必须在引用发生增减时对引用计数做出正确的增减,而如果漏掉或者更改了引用计数就会引发很难找到的内存错误。

  • 引用计数不适合并行处理。如果多个线程同时对引用计数进行增减的话,引用计数的值就可能会产生不一致的问题(结果就会导致内存错误),为了避免这样的事情发生,对引用计数的操作必须采用独占的方式来进行。如果引用计数操作频繁发生,每次使用都要使用加锁等并发操作其开销也不可小觑。

d、其他方式:python中的分代

​ 分代回收的整体思想是:将系统中的所有内存块根据其存活时间划分为不同的集合,每个集合就成为一个“代”,垃圾收集频率随着“代”的存活时间的增大而减小,存活时间通常利用经过几次垃圾回收来度量。

Python默认定义了三代对象集合,索引数越大,对象存活时间越长。

​ 举例: 当某些内存块M经过了3次垃圾收集的清洗之后还存活时,我们就将内存块M划到一个集合A中去,而新分配的内存都划分到集合B中去。当垃圾收集开始工作时,大多数情况都只对集合B进行垃圾回收,而对集合A进行垃圾回收要隔相当长一段时间后才进行,这就使得垃圾收集机制需要处理的内存少了,效率自然就提高了。在这个过程中,集合B中的某些内存块由于存活时间长而会被转移到集合A中,当然,集合A中实际上也存在一些垃圾,这些垃圾的回收会因为这种分代的机制而被延迟。

3、进程间通信与线程间通信

​ 每个进程有自己的地址空间。两个进程中的地址即使值相同,实际指向的位置也不同。进程间通信一般通过操作系统的公共区进行。同一进程中的线程因属同一地址空间,可直接通信。

a、进程之间

  • 管道( pipe ):管道是一种半双工的通信方式,数据只能单向流动,而且只能在具有亲缘关系的进程间使用。进程的亲缘关系通常是指父子进程关系。

  • 有名管道 (namedpipe) : 有名管道也是半双工的通信方式,但是它允许无亲缘关系进程间的通信。

  • 信号量(semophore ) : 信号量是一个计数器,可以用来控制多个进程对共享资源的访问。它常作为一种锁机制,防止某进程正在访问共享资源时,其他进程也访问该资源。因此,主要作为进程间以及同一进程内不同线程之间的同步手段。

  • 消息队列( messagequeue ) : 消息队列是由消息的链表,存放在内核中并由消息队列标识符标识。消息队列克服了信号传递信息少、管道只能承载无格式字节流以及缓冲区大小受限等缺点。

  • 信号 (signal ) : 信号是一种比较复杂的通信方式,用于通知接收进程某个事件已经发生。

  • 共享内存(shared memory ) :共享内存就是映射一段能被其他进程所访问的内存,这段共享内存由一个进程创建,但多个进程都可以访问。共享内存是最快的 IPC 方式,它是针对其他进程间通信方式运行效率低而专门设计的。它往往与其他通信机制,如信号量,配合使用,来实现进程间的同步和通信。

  • 套接字(socket ) : 套解口也是一种进程间通信机制,与其他通信机制不同的是,它可用于不同及其间的进程通信。

b、线程之间

  • 锁机制:包括互斥锁、条件变量、读写锁 *互斥锁提供了以排他方式防止数据结构被并发修改的方法。 *读写锁允许多个线程同时读共享数据,而对写操作是互斥的。 *条件变量可以以原子的方式阻塞进程,直到某个特定条件为真为止。对条件的测试是在互斥锁的保护下进行的。条件变量始终与互斥锁一起使用。

  • 信号量机制(Semaphore):包括无名线程信号量和命名线程信号量

  • 信号机制(Signal):类似进程间的信号处理 ​

    线程间的通信目的主要是用于线程同步,所以线程没有像进程通信中的用于数据交换的通信机制。

4、死锁产生的四个条件

  • 互斥条件:资源是独占的且排他使用,进程互斥使用资源,即任意时刻一个资源只能给一个进程使用,其他进程若申请一个资源,而该资源被另一进程占有时,则申请者等待直到资源被占有者释放。

  • 不可剥夺条件:进程所获得的资源在未使用完毕之前,不被其他进程强行剥夺,而只能由获得该资源的进程资源释放。

  • 请求和保持条件:进程每次申请它所需要的一部分资源,在申请新的资源的同时,继续占用已分配到的资源。

  • 循环等待条件:在发生死锁时必然存在一个进程等待队列{P1,P2,…,Pn},其中P1等待P2占有的资源,P2等待P3占有的资源,…,Pn等待P1占有的资源,形成一个进程等待环路,环路中每一个进程所占有的资源同时被另一个申请,也就是前一个进程占有后一个进程所申请的资源。

    ​ 以上给出了导致死锁的四个必要条件,只要系统发生死锁则以上四个条件至少有一个成立。事实上循环等待的成立蕴含了前三个条件的成立,似乎没有必要列出然而考虑这些条件对死锁的预防是有利的,因为可以通过破坏四个条件中的任何一个来预防死锁的发生。

5、快速排序

学习排序算法的时候觉得快排是最懵的一个,刚好看到一个python实现的非常清楚(https://blog.csdn.net/mrlevo520/article/details/77829204)。

  1. 先从数列中取出一个数作为基准数。
  2. 分区过程,将比这个数大的数全放到它的右边,小于或等于它的数全放到它的左边。
  3. 再对左右区间重复第二步,直到各区间只有一个数。

看代码最清楚了

  # 快排 分片的思想+递归的思想,这是取了第一个为基准值,栈高为O(log(n)),栈长O(n),所以运行时间为栈高x栈长,也就是算法平均运算时间为O(nlog(n))
  
  def quickSort(array):
      if len(array) < 2:
          return array
      else:
          pivot = array[0]
          less = [i for i in array[1:] if i < pivot]
          greater = [j for j in array[1:] if j > pivot]
          return quickSort(less) + [pivot] + quickSort(greater)
  
  print quickSort([1,5,2,6,9,3])

6、TCP的握手与挥手

首先我们知道,TCP协议是面向字节流的全双工通信,也就是两方可以同时进行收发消息,是一种面向连接的,根据请求、应答来保证传输可靠性的一种通信协议

  • 第一次a->b:b确认a的发信机和自己的收信机是没有问题的,可以正常发送和接受消息

  • 第二次b->a:b回应,a收到了。这时a可以确认的是,自己和b的收发信机都是好的。此时b并不知道a是否收到回应,即不确定a的收信机以及自己的发信机是否完好 第三次a->b:a对b的回应进行回应。这时a很清楚,双方收发信机都是好的,自己的这次回应b肯定能收到(正常情况下),这个回应的目的只是消除b对a的收信机和b自己的发信机的担心。然后,a不必等b的再次回应就可以正式发信了。

  • 第三次 ACK 丢了没有太大问题。只要b后面接收到a的数据包过来,就可以确认连接已建好。如果a不发送别的数据包,那么b会超时重传第二次的握手信息。

让我们想一想,如果是两次的话,a发送请求,b应答并分配资源若b的应答没有到达a端,a认为连接未建立,而b认为建立了a会在一段时间内保留分配的资源如果大量a这样请求,b会崩溃。

至于为啥不是四次?既然三次已经足够了,为啥还要再来一次?简单而粗暴的理由~

  1. 现在是关于四次挥手,在连接完成之后呢,需要关闭连接。

关闭连接的过程:

1)客户端发出段7,FIN位表示关闭连接的请求

2)服务器发出段8,应答客户端的关闭连接请求。

3)服务器发出段9,其中也包含FIN 位,向客户端发送关闭连接请求。

4)客户端发出段10,应答服务器的关闭请求

注意:服务器的应答和关闭连接请求通常不合并在一个段中,因为有连接半关闭的情况,这种情况下客户端关闭连接之后就不能再发送 数据给服 务器了,但是服务器还可以发送数据给客户端,直到服务器也关闭连接。