Haoson的博客 2016-02-27T12:17:08+00:00 haoweiqinghaoson@gmail.com 手游弱网络通信优化 2015-07-19T00:00:00+00:00 Weiqing Hao http://Haoson.github.io/optimization-of-mobile-game-in-the-unstable-network
  • 1. 移动网络的弱点
  • 2. 弱网给游戏带来的影响
  • 3. 弱网处理方案
  • 4. 结语
  • 1. 移动网络的弱点

    1. 网络制式多:2G/3G/4G/wifi;
    2. 各种制式之间网络速度差异大;
    3. 地理位置变动中网络切换(如发生制式之间的切换、基站之间的切换)等;
    4. 信号强度弱(建筑死角, 隧道, 深山老林, 无线信号会衰弱);
    5. 信号不稳定(如在高速移动的火车上, 会有明显的多普勒效应, 网络延迟抖动);
    6. 网络拥塞(人口密集的场所, 运营商降低人均通信带宽, 网络延迟增大)。

    2. 弱网给游戏带来的影响

      简单来说, 弱网络环境会有更高的丢包率, 更大的延迟抖动, 不稳定的网络连接。具体到对游戏的影响,有三个方面:

    1. 网速慢导致请求响应时间变长甚至断线;
    2. 网络不稳定导致上行包/下行包丢失;
    3. 弱网络加剧异步设计系统的响应延迟。

    3. 弱网处理方案

      针对上述提到的弱网给游戏带来的三点影响,下面分别给出方案处理。

    3.1 如何解决网速慢导致响应延迟?

    • 客户端保证一发一/多收模式

      大部分协议都有一个定时器,在超时时间内,客户端期待服务端回包。根据协议的不同,服务端回包的个数也不同,例如对玩家购买金币的操作,服务端在回多个包,包括扣钻石的回包、反馈操作是否成功的回包等。不管是一发一收还是一发多收模式,一般情况下,每一个上行包都有一个对应的反馈下行包来表示操作结束,在这个反馈下行包之前,服务端也可能会发送别的回包给客户端。
      补充一点的是,有一些协议不需要期待回包,所以客户端也会启动一个协议白名单机制,在白名单之中的协议,客户端发包之后不期待回包就可以继续操作。常见的有GM相关的协议、平台请求相关协议等都不需要期待回包。
      在超时时间之内,客户端对UI交互进行锁定(模态菊花),也就是说,超时间时间之内,还没有期待到服务端的回包的情况下,玩家无法进行下一步的操作。为了保证玩家的体验,模态菊花会有一个淡入的效果,延迟越低模态菊花越不明显。
      如果在超时时间之内客户端没有收到回包,此时客户端会启动超时探测。

    • 客户端超时探测

      对于在超时时间内没有期待到回包的情况,客户端会主动向服务器发送一个探测包,这个探测包只包含一个包头,不携带任何信息。在发送探测包的同时,客户端同样为这个探测包启动一个定时器,如果在超时时间内探测包有回包,我们认定上行包丢失(也有下行包丢失的可能,下面会详谈这个问题),直接忽略这种情况;如果探测包在超时时间内也没有回包,那么这时候我们断定是网络出现了问题,直接弹出提示框告知玩家请求失败,由玩家手动点击重连进入断线重连程序。

    • 断线重连

      以分区分服玩法的游戏为例,玩家完整登陆的过程是首先通过接入层的负载均衡机制连接到一个game zone上,然后以选中的zone为载体,通过DBProxy从DB中拉取角色数据。理想的状态下,想要优化玩家体验,断线重连过程就应该不需要重新走原来复杂的流程,这里又可以分几个小点优化:
    1) 登陆简化
      弱网环境下,玩家出现掉线的情况比较频繁,所以登陆流程需要细分,区分完整登陆和断线重连的情况,例如客户端在断线重连的时候可以简化鉴权。
    2) 延迟下线机制
      当玩家出现掉线后,服务端不马上清除玩家角色数据,而是为玩家角色数据设置一个延迟下线时间(如5分钟),然后在主程序中的时间事件里(tick定时回调)检查角色数据状态,如果玩家在时间未过期之前重新连接上,就可以直接将角色数据下发客户端,否则清除数据,下次玩家重新登陆上走完整登陆流程。
    3) 会话机制
      前面说到玩家登陆是通过接入层的负载均衡连接到一个game zone上的,如果玩家重连上的不是之前的那个zone,那么即使有延迟下线机制,还是得走完整登陆流程,所以这里还需要开启接入层的会话机制配合延迟下线机制。接入层的会话机制就相当于负载均衡的逻辑当中有一个保护时间,在这个保护时间之内玩家通过接入层连接到一个zone的时候将不再执行负载均衡,而是直连到我们的zone上。这里一个小细节是接入层的会话机制设置的时间最好与延迟下线的时间一致。

    3.2 如何解决丢包问题?

      弱网络环境导致丢包具体来说有两种情况,一是上行包丢失,即服务器并未收到请求协议,客户端只需要重新请求即可;二是下行包丢失,服务器已经收到请求协议并处理,由于网络问题,客户端未收到回包,客户端不知道服务器是否已处理以及处理的返回数据是什么。我们这里主要讨论的是如何解决下行包丢失的问题。
      下行包丢失又可以细分两种情况,一是对于客户端收不到回包不会影响玩家收益的请求,则不需要做特别处理,这类请求回包丢失时,玩家一般可以重新登陆或刷新界面可以恢复数据的请求,典型的有领取任务奖励;二是对于客户端收不到回包会影响玩家的请求,例如战斗过程中加血,如果这类关键请求在超时时间内没有收到回包,客户端需要自动重复发送上次请求,如果是网络问题,走断线重连流程,如果是下行包丢失,则需要相应的机制来处理。主要手段是消息会话机制。

    • 消息会话机制

      消息会话机制的实现思路是客户端和服务端配合工作,首先客户端为每个协议请求分配一个唯一的会话ID(序列号),当客户端进行重复操作的时候,如果上一个请求没有收到服务端的响应,这个时候就会分配相同的会话ID。服务端收到相同的会话ID则认为是重复的请求,重复的请求本质上是上一次请求的历史回放,应对回放的请求,最直接的解决方案就是返回回放的回复数据。因此只需要在逻辑处理完“非重复请求”后以及将回复数据提交网络层返回客户端之前,对回放数据进行缓存,在后续遇到这次回复的重复请求时,绕过逻辑处理,直接将之前缓存的回复数据返回客户端即可。
      实现上,会话ID是在玩家初次完整登陆的时候服务端进行初始化(可以随机产生),并下发客户端,客户端上行消息中的ID需要跟当前的服务器一致(最近一次收到的下行包中的ID),服务端收到客户端注册的协议请求且检查ID合法后,将下行发送的ID加一。这里服务端在检查ID的时候,就可以发现哪些消息是重复消息,能够知道是否已经处理过该消息,更进一步,服务端可以进行下行包缓存处理,当处理重复请求时,从缓存中取出最旧的下行包ID与客户端重复上报的下行包ID比较,只要还在缓存中的,就可以依次下发满足条件的包,如果客户端上报的下行包ID过旧,下发可能失败,服务端回复错误包,客户端可以主动发起数据同步。

    3.3 如何解决异步设计系统在弱网下的表现?

      公司目前游戏服务端大多采用多进程异步通信模型,这势必存在大量复杂的网络设计,很多功能存在着异步操作,例如访问公司关系链。这种系统架构下,消息经过的环节越多,相当于逻辑越复杂,中间流程耗时就越多,如果这时候碰上弱网络,相当于这么复杂的异步逻辑将会重复得到执行,这也就加剧了弱网络的负担。从上面分析可知,我们无法避免异步通信带来的开销,但是我们可以优化弱网络情况下异步通信的重复消耗,主要手段是数据缓存。

    • 数据缓存

      数据缓存机制其实已经在上面提到,例如下行包的缓存来优化消息丢包引起的重复请求问题,玩家角色数据的缓存来优化断线重连等。对于异步设计系统,我们可以缓存平台信息、用户关系链数据等等,以此来避免弱网络下重复的异步数据传输或者异步逻辑。

    4. 结语

      文章笼统的给出了一些手游弱网络优化手段,这些手段对不同类型的手游可能并不都适用或者必要,在技术之外我们也要考虑实现这些手段的性价比。

    ]]>
    从一行代码引发的bug说起 2015-06-08T00:00:00+00:00 Weiqing Hao http://Haoson.github.io/one-line-fix
  • 1. bug描述
  • 2. libcurl概述
  • 3. bug分析
  • 4. 困难&经验
  • 5. 链接
  • 1. bug描述

      最近发现我们游戏的cardinfo进程的client会出现不够用(目前上限500)的情况。
    1) cardinfo进程是一个与外部开放平台交互的进程,例如调用QQ开放平台实现游戏内一键加群绑群。
    2) cardinfo进程底层使用libcurl库+epoll的事件模式与外部进行通信。这个外部包含两个,一是开放平台,二是cardzone(gamesvr)进程。
    3) cardinfo进程的client概念是一个请求就产生一个client,这个请求是由cardzone发出的。一般流程是玩家点击进入帮派界面的时候就会向cardzone发 送一条查询帮派群的请求,cardzone转发请求到cardinfo进程,cardinfo进程向开放平台发出请求,收到异步消息之后向cardzone
    4) 正常情况下,请求结束之后,client马上释放,释放后的client可以供下一个请求使用。

    2. libcurl概述

      libcurl是一个开源的网络传输库,cardinfo进程使用libcurl实现http服务。这里简单介绍一下libcurl库,方便后续的bug分析。
      libcurl主要提供了两种接口:easy interface和multi interface。两者之间的区别主要是:前者只是最简单的阻塞式服务,后者可以实现非阻塞式服务。Cardinfo进程使用的是multi interface。multi interface是建立在easy interface基础之上的,它只是简单的将多个easy handler添加到一个multi stack,而后同时传输而已。

    2.1 使用easy interface

    1) 创建一个easy handle,easy handle用于执行每次操作(数据传输)。
    2) 在easy handle上设置属性和操作。使用curl_easy_setopt函数可以设置easy handle的属性和操作,设置这些属性和操作控制libcurl如何与远程主机进行数据通信。
      对于easy interface的使用,关键在于curl_easy_setopt函数,通过这个函数,我们可以设置相关的属性,包括设置url,timeout,http协议(究竟是post,get还是其他),设置这些属性的同时还可以设置相应的回调函数,例如如果我们想对接收到的数据进行保存,可以设置CURLOPT_WRITEFUNCTION属性并注册回调函数:

        curl_easy_setopt(easy_handle, CURLOPT_WRITEFUNCTION, write_data);
    

    3) 调用curl_easy_perform,将执行真正的数据通信。curl_easy_perfrom将连接到远程主机,执行必要的命令,并接收数据。当接收到数据时,先前设置的回调函数将被调用。

    2.2 使用multi interface

    1) 使用curl_multi_init()函数创建一个multi handler
    2) 使用curl_easy_init()创建一个或多个easy handler,按照上述介绍的使用easy interface来对每个easy handler设置相关的属性
    3) 通过curl_multi_add_handler将这些easy handler添加到multi handler
    4) 调用curl_multi_perform进行数据传输

    2.3 使用epoll+multi interface(重点)

      libcurl对大量请求连接提供了管理socket的方法,用户可使用select/poll/epoll事件管理器监控socket事件。Cardinfo就是通过epoll来操作multi interface的。其实每个easy handler在低层就是一个socket,通过epoll来管理这些socket,在有数据可读/可写/异常的时候,通知libcurl读写数据,libcurl读写完成后再通知用户程序改变监听socket状态。这里要重点关注两个属性的设置:

        curl_multi_setopt(multi, CURLMOPT_SOCKETFUNCTION, mycurl_socket_callback); 
        curl_multi_setopt(multi, CURLMOPT_TIMERFUNCTION, mycurl_timeout_callback);
    

    详细的见下代码分析。

    3. bug分析

    3.1 第一阶段:分配释放有问题?

    bug日志记录 从日志文件来看,分配client失败,找到执行失败的函数new_client,定义如下: new_client函数定义   new_client逻辑很简单,从池子中取free block,成功就返回client指针,否则返回NULL。如果这里出错,那就是池子中的free block被耗尽。gdb断点确认下: gdb确认   果然,所有block已经全部分配出去。get_freeblock函数是blocklist提供的接口,blocklist作为基础数据结构被很多项目使用,这里面出现bug的可能性微乎及微,这里不提供源码,实际上在排查过程中,也进入get_freeblock中断点查看分配过程,确实没有问题。
    那么可能出现问题的是没有正确释放,先看一下free_client函数的定义: gdb确认   free_client的逻辑也是十分简单,free_block也是blocklist底层数据结构提供的接口,出现bug的可能性也很小。
    综上述,排除了分配释放函数出错的可能性。

    3.2 第二阶段: 请求太频繁,client上限不够?

      目前client上限是500,一个请求等同于一个client,如果请求太频繁,是有可能导致分配失败。从两个方面验证这个假设:
    1) 从之前正常分配释放的日志来看:
    分配client日志   new一个client之后,处理请求,请求结束之后立马free client。对于这种短链接,即使某一时刻请求数飙升超过上限,之后的时候也应该有请求能够得到响应并打印日志。实际上在错误日志中我们发现一旦client满了之后,再也不能够处理任何请求,也就说,所有block一旦分配完毕,再也没有被释放出来。从这个角度来看,应该可以排除请求太频繁导致client超上限处理不过来的猜测。
    2) 分析分配频率和释放频率
      实际排查过程中我试图通过定量分析new client的频率和free client的频率来验证是否是请求太频繁的原因。对于new client频率的计算主要手段是统计日志,统计每分钟new 的数量,随机抽样十分钟,然后求平均数,基本可以得出平均每分钟new client的数量;对于free client的频率统计相对较麻烦,主要是通过分析调用方的调用频率,这里暂且不详细描述,可以参见第三阶段分析。
      这种分析方法非常麻烦,我也在这里浪费了不少时间,其实通过第一种的逻辑分析就可以排除请求太频繁的猜测,但是当时总觉得定量比定性分析准确。

    3.3 第三阶段: 调用方出现了问题?

    分配和释放的代码如下(做了简化处理): new_client调用函数定义 free_client调用函数定义   可以看出,new client调用时机 请求到达cardinfo进程的时候,free client有两处,一处是curl_multi_add_handle调用失败的时候,第二处是curl_multi_info_read从multi handler成功读取完数据的时候,也就是请求完成的时候释放。
      对于new client,可以看到没有并无任何令人迷惑的地方,断点调试之后也没发现异常,这里暂且不表,重点分析free client的两处场景,分析之前先把整个流程和重要函数调用介绍一遍:
      curl_multi_add_handle作用在libcurl概述章节有简单介绍。而且在libcurl概述章节中的使用用epoll+multi interface小节中,提到了两个重要属性的设置:

        curl_multi_setopt(multi, CURLMOPT_TIMERFUNCTION, mycurl_timeout_callback);
        curl_multi_setopt(multi, CURLMOPT_SOCKETFUNCTION, mycurl_socket_callback);
    

    这里再次提到是因为调用curl_multi_add_handle的时候,最后就会调用mycurl_timeout_callback回调函数。先看一下这个回调函数的定义: mycurl_timeout_callback回调函数定义   该回调函数的原型见官网1,该函数的调用时机是当函数中的第二个参数(left)改变的时候就会被调用,第二个参数的改变由libcurl完成,先看着curl_multi_add_handle调用关系图是:

        curl_multi_add_handle-->update_timer-->mycurl_timeout_callback。
    

    这里的update_timer就会改变left值,最终也就是的回调被调用。

    再分析一下mycurl_timeout_callback:
      回调中先调用curl_multi_socket_action2,然后调用mycurl_readinfo(见上定义)。curl_multi_socket_action将初始化请求并得到一个socket(fd),然后调用上述提到的第二个重要参数设置的mycurl_socket_callback回调。先看看这个回调的函数定义(代码有简化): mycurl_socket_callback回调函数定义-1 mycurl_socket_callback回调函数定义-2   可以看到mycurl_socket_callback2主要是为epoll注册事件,epoll wait函数在proc程序中,程序片段如下: epoll_wait使用   可以看出,由于回调的存在,libcurl调用关系比较复杂,这里我把重要调用过程通过图的方式展示出来: libcurl调用关系   分析完流程,回到bug分析,也就是free client到底有没有成功释放,可以看到两处free动作:

    • 第一处是add handle未成功的时候,也就是数据传输之前,handle获取失败,直接free掉,这里相当于检查,通过错误日志来看,client满之前数据传输日志已经打印出来,所以排除掉这里出问题的可能。
    • 第二处是在请求处理结束之后正常释放,如果这里出问题,那也就是handle状态不是CURLMSG_DONE,所以代码没有执行到这一步。继续看日志,由于mycurl_readinfo(定义见上)中打印了日志, 日志打印 且日志文件中也出现了要打印的内容,这里让人无比疑惑,到底是谁出错导致没释放?这个地方组内同事帮我做了两件很重要的工作:
      一是发现了日志文件中出现的一两次的错误(40多万条日志数据中出现了两次): errno9错误 二是为线上cardinfo进程的错误日志添加了更多输出信息:打印每次分配释放的序列号。
        对于错误日志中出现的errno=9,也就是bad file fd,文件描述符出现了问题!查看该进程的fd使用情况: socket fd状态 Socket没有释放,而且随着时间会不断增多。为什么socket没有释放?程序中全部采用短链接,应该不会出现不释放的情况,继续分析。

      通过分析新添加的带有分配释放序列号的日志文件,我分别统计了new client[2]和free client[2]出现的次数,new了332次,free了331次,果然没有释放。找到最后一次new client[2]打印的地方,可以看到: new_client详细日志   仔细看这一段日志,可以发现new client[2]之后,epoll_ctl add fd=20成功,随后马上epoll_ctl del sock fd=20,但是这个删除fd的操作对象竟然是client[0]!
      程序中唯一一处操作socket fd的地方就是mycurl_readinfo(定义见上)中的close函数,这里逻辑是协议接收完毕之后,立马显示删除socket,然后给cardzone进程回包,free client等。

    3.4 bug总结

      通过查看libcurl官方文档,可以知道默认情况下libcurl完成一个任务以后,出于重用连接的考虑不会马上关闭(每一个easy handle底层是一个socket),如果没有新的TCP请求来重用这个连接,那么只能等到CLOSE_WAIT超时。这种重用包含两层含义:一是对于同一个请求的重用,给定时间内新的请求过来可以直接连接;二是如果要将该handle对不同的请求进行重用,那么只需要curl_easy_reset3来将该handle重新初始化到创建时的状态即可。两种情况下连接都会保持。如果我们手动close socket,虽然该handle已经完成了任务,但在高并发的情况下,很有可能close掉的是一个已经给其他请求重用的handle。这样问题就出现了,一旦close掉了被分配出去的socket,那么在该socket上将不会有数据传输,如果不会有数据传输,那么curl_multi_info_read执行的时候该CURL的状态就不会走到CURLMSG_DONE态,如果走不到该状态,那么free client将不会被调用。

    4. 困难&经验

    • 调试的时候遇到的最大的困难是无法重现,通过上述分析可知bug重现的条件是在高并发下有概率的出现,所以在线下重现该bug的时候浪费了相当多的时间;
    • Libcurl库有非常多的坑,各种异步、回调很容易让人踩坑,这里还是不涉及多线程的情况,如果一旦有多线程,问题会更复杂;
    • 最大的收获是错误日志是调试时候的好东西,特别是对于线上问题,没法直接在线上debug,只有靠日志。

    5. 链接

    ]]>
    C++内存管理:自己实现malloc/free 2014-10-09T00:00:00+00:00 Weiqing Hao http://Haoson.github.io/implementing-malloc-and-free
  • 1. 说明
  • 2. 代码
  • 1. 说明

      C++内存管理:自己实现malloc/free是C++内存管理系列第三篇,从前面的几篇内存管理的博客可以知道,如果不是直接使用系统调用向操作系统申请空间,那么最终分配/释放内存的动作都会由CRT提供的malloc/free完成,然后上层可能再对得到的内存进行一些管理,比如内存池。那么malloc和free到底是怎么工作的,我将会在这篇博客中实现一个简单版本的malloc和free。

    2. 代码

      //to do

    ]]>
    C++内存管理:自己实现内存池 2014-09-13T00:00:00+00:00 Weiqing Hao http://Haoson.github.io/pooled-allocation
  • 1. 说明
  • 2. 代码
  • 3. 分析
  • 4. 改进
  • 5. 固定大小的内存分配器
  • 6. 总结
  • 1. 说明

      C++内存管理:自己实现内存池是C++内存管理系列第二篇,前一篇博客原语篇提到可以重载operator new和operator delete,如果class提供自己的member operator new和operator delete,便可以自己承担内存管理的责任,这也称为Per-class Allocation。
      那么什么时候class需要提供自己的member operator new和operator delete呢?一般来说,如果某个类有大量小型对象的分配和释放,那么通常就要进行内存控制,定制自己的operator new和operator delete,定制版的性能通常这时候都会胜过缺省版本的性能。因为编译器自带的operator new和operator delete的实现主要是用于处理各种需求,如大块内存分配,小块内存分配,大小混合型内存分配等等,而且缺省版的实现还必须接纳各种分配形态,范围从程序活动期间的少量区块动态分配到大量短命对象的持续分配和归还。所以对于某些应用程序,某些class,定制自己的operator new和operator delete有助于获得程序的性能的提升。
      这里主要focus在内存池这块,暂不考虑多线程问题。如果要做到线程安全,可以使用互斥量(mutex),做法是用RAII手法封装mutex的创建、销毁、加锁、解锁这四个操作,不用手动调用lock()和unlock()函数,一切交给栈上封装互斥量的那个对象的构造和析构函数负责,这个对象的生命期正好等于临界区。C++11提供了lock_guard类就是这个作用。

    2. 代码

        #include<cstddef>
        class Foo{
            private:
                int i;
            public:
                Foo(int x):i(x){}
                int get(){
                    return i;
                }
                static void* operator new(size_t);
                static void operator delete(void*,size_t);
            private:
                static Foo* free_list;
                static const int foo_chunk=24;
                Foo* next; //[2]
        };
    
        Foo* Foo::free_list = nullptr;
        void* Foo::operator new(size_t size){
            if(size!=sizeof(Foo)){//如果大小出错,分配内存动作交给global operator new [1]
                return ::operator new(size);
            }
            Foo* p;
            if(!free_list){//当自由链表为空或者用光的时候,再创建一条自由链表
                size_t chunk = foo_chunk*size;
                free_list = p = reinterpret_cast<Foo*>(new char[chunk]);
                while((p++)!=free_list+chunk-1){//将分配得到的所有对象链起来
                    p->next = p+1;
                }
                p->next = nullptr;
            }
            p = free_list;
            free_list = free_list->next;//分配出自由链表上的第一个对象
            return p;
        }
        
        void Foo::operator delete(void* ptr,size_t size){
            if(ptr==nullptr)//C++保证删除null指针永远安全
                return;
            if(size!=sizeof(Foo)){//谁分配,谁归还(如果是global operator new分配的内存就应该由global operator delete归还)
                ::operator delete(ptr);
                return;
            }
            Foo* p = static_cast<Foo*>(ptr);
            p->next  = free_list;//未实际释放空间,只是重新将归还的对象挂到free list上
            free_list = p;
        }
    

    3. 分析

      上述代码为Foo类重载了operator new和operator delete,对象new的时候使用自由链表一次性分配多个对象(bulk allocation),然后从”对象池”中取对象给用户,对象delete的时候不直接归还(Caching),而是将归还的对象挂到自由链表头。
      对于代码有一点简单说明:
      [1]处判断size是否与sizeof(Foo)大小是否相同,operator new的参数是由编译器传过来的,为什么这里会出现不一致的情况呢?要注意的是member operator new/delete会被继承,对子类使用new运算符时,可能会调用父类中定义的operator new()来获取内存。但是,在这里,内存分配的大小,不应该是sizeof(父类),而是sizeof(子类)。所以为了防止重载operator new/delete给子类带来的副作用,对于子类的内存分配,这里还是交给global operator new处理。
      做了一个简单测试,性能上(在动态分配大量小对象的时候)确实有了一定提升

        Begin clock=30000 End clock=70000 1000000 OriginalFoo,take 40000clocks.
        Begin clock=70000 End clock=100000 1000000 Foo,take 30000clocks.
    

      这里性能上的提升不仅仅是分配和回收的速度变快,也节省了一定的内存空间,前一篇博客原语篇提到new分配内存时,最终还是由malloc来分配,malloc分配空间的时候一般会在原始对象大小上再加一个”cookie”来记录对象大小,per-class allocation实质上是挖一大块空间来自己切割分配,这样当动态分配大量小对象的时候,小对象的”cookie”自然也就节省掉了。但是上述代码为了使用自由链表,引入了一个next指针,空间又浪费了,下面改进章节将会这个指针进行优化。
      这段代码最大的缺点是永远不会释放空间,而且如果不断创建对象又不释放的话,自由链表会越长越大。如果引入变量记录相关信息,也许可以解决,不过也要考虑花费的代价值不值,毕竟重载operator new/delete就是为了应对特殊场合的,如何取舍还得看具体场景,也许某些场合下完全不释放空间不是问题。

    4. 改进

      前面提到为了使用自由链表,引入了一个next指针,这个指针就是为了将对象链起来,既然对象已经归还,那么是否可以借用对象的前4个bytes(32位系统上)来作为链接下一个对象的next指针呢?当然是可以的。所以上述代码可以稍加改造。

        #include<cstddef>
        class Foo{
            private:
                union{//匿名union [1]
                    int i;
                    Foo* next;
                };
            public:
                Foo(int x):i(x){}
                int get(){
                    return i;
                }
                static void* operator new(size_t);
                static void operator delete(void*,size_t);
            private:
                static Foo* free_list;
                static const int foo_chunk=24;
        };
    

      使用匿名union即完成上述改造,匿名union不用于定义对象,它的成员的名字出现在外围作用域中,也就是声明变量的作用,匿名union仅仅通知编译器它的成员变量共同享一个地址,而变量本身是直接引用的。通过引入匿名union,也就实现了上面说的借用对象本身的内存作为分配器的自由链表的分配指针的功能。
      如果有很多类都需要重载member operator new的话,那么实现逻辑同上应该是基本一致,从代码的可重用和模块化的角度来说,我们应该将内存分配的功能封装起来,这样每个类就可以重用他们而不用管到底如何实现,这也体现了单一职责原则。下面实现一个不太复杂的固定大小的内存分配器,设计思想参考MFC的CFixedAlloc。

    5. 固定大小的内存分配器

        //memory_pool.h
        struct MemoryPool{
            MemoryPool* next;//每次挖一大块内存,当使用完再挖一块,各大块之间通过next指针链接,组成一个自由链表
            void* data(){//每次挖出的一大块内存的前几个字节放next指针(等价于一个MemoryPool对象大小),之后的空间才会分配出去
                return this+1;
            }
            static MemoryPool* create(MemoryPool*& head,size_t alloc_size,size_t block_size);
            void freeDataChain();//释放所有申请的空间
        };
        //memory_pool.cpp
        MemoryPool* MemoryPool::create(MemoryPool*& head,size_t alloc_size,size_t block_size){
            assert(alloc_size>0 && block_size>0);
            MemoryPool* p = reinterpret_cast<MemoryPool*>(new char[sizeof(MemoryPool)+alloc_size*block_size]);
            p->next = head;//新挖出的一大块内存入链
            head = p;//head作为自由链表的头
            return p;
        }
        void MemoryPool::freeDataChain(){
            MemoryPool* p = this;
            MemoryPool* temp;
            while(p){
                temp = p->next;
                char* cptr = reinterpret_cast<char*>(p);
                delete[] cptr;
                p = temp;
            }
        }
    

      MemoryPool类只负责申请一大块内存并链接起来组成自由链表(把每次申请的一大块内存看做一个节点),create函数具体建立一条自由链表为特定大小(alloc_size)区块服务。

        //fixed_alloc.h
        #include"memory_pool.h"
        class FixedAlloc{
            public:
                FixedAlloc(size_t nAllocSize,size_t nBlockSize=32);
                size_t getAllocSize(){
                    return allocSize;
                }
                void* allocate();//分配allocSize大小的块
                void deallocate(void* p);//回收alloc出去的内存
                void deallocateAll();//释放所有从这个alloc分配出去的内存
                ~FixedAlloc();
            protected:
                union Obj{// [1]
                    union Obj* free_list_link;
                };
                size_t allocSize;//分配块的大小
                size_t blockSize;//每次申请的块的数目
                MemoryPool* blocks_head;//所有区块组成的自由链表头
                Obj* free_block_head;//未分配出去的区块头
        };
        //fixed_alloc.cpp
        FixedAlloc::FixedAlloc(size_t nAllocSize,size_t nBlockSize){
            assert(nAllocSize>=sizeof(Obj));
            assert(nBlockSize>1);
            //alloc的自由链表的设计思想是借用对象的前4个字节(32位系统上)来作为next指针链接block,如果对象本身大小比4个字节还小,那么必须向上调整对象大小
            if(nAllocSize<sizeof(Obj))
                nAllocSize = sizeof(Obj);
            if(nBlockSize<=1)//blockSize小于等于1让内存池失去了存在的意义
                nBlockSize = 32;
            allocSize = nAllocSize;
            blockSize = nBlockSize;
            blocks_head = nullptr;
            free_block_head = nullptr;
        }
        FixedAlloc::~FixedAlloc(){
            deallocateAll();//真正的归还
        }
        void FixedAlloc::deallocateAll(){
            blocks_head->freeDataChain();
            blocks_head = nullptr;
            free_block_head = nullptr;
        }
        void FixedAlloc::deallocate(void* p){
            if(p){//归还的block入链,挂在最前面
                Obj* objptr = static_cast<Obj*>(p);//归还回来的block里面数据已经没有用,直接覆盖,强制转型,作为入链的一个小节点
                objptr->free_list_link = free_block_head;
                free_block_head = objptr;
            }
        }
        void* FixedAlloc::allocate(){
            if(!free_block_head){//内存池中没有未分配的block
                MemoryPool* p = MemoryPool::create(blocks_head,blockSize,allocSize); 
                Obj* objptr = static_cast<Obj*>(p->data());//MemoryPool挖出的一大块的前几个字节是记录信息作用(一个MemoryPool对象)
                free_block_head = objptr;
                Obj* temp;
                for(size_t i=0;i!=blockSize-1;++i){//将所有block链起来
                    temp = reinterpret_cast<Obj*>(reinterpret_cast<char*>(objptr)+allocSize);//每一个入链的小节点大小都是allocSize
                    objptr->free_list_link =temp;
                    objptr = temp;
                }
                objptr->free_list_link = nullptr;//最后一个block的指针指向nullptr
            }
            void* p = free_block_head;//将第一个区块分配出去
            free_block_head = free_block_head->free_list_link;//free_block_head指向剩下的区块头
            return p;
        }
    

      FixedAlloc类对外提供接口,FixedAlloc类需要的内存从MemoryPool类拿,MemoryPool类通过create函数申请一大块,FixedAlloc类拿到这一大块之后进行切割,形成一个自由链表,然后从这个自由链表中分配一块给用户。
      fixed_alloc.h文件中有一处简单说明下,就是标注了[1]的地方。这个union对象的存在就是前面提到的,为了借用对象本身的内存来作为指向下一个小区块的指针,从而形成自由链表来进行内存管理。因为对象归还后,对象内是什么已经不重要了,所以我们直接覆盖对象数据,强制转型为Obj类型的指针指向这个区块。
      做这么多主要还是为了把内存分配的功能封装起来,通过以上,这部分工作基本完成。那么怎么使用这个分配器呢?拿之前的Foo类继续做改造。

        //advanced_foo.h
        #include"fixed_alloc.h"
        class Foo{
            private:
                int i;
            public:
                Foo(int x):i(x){}
                int get(){
                    return i;
                }
                static void* operator new(size_t size);
                static void operator delete(void*p,size_t size);
            protected:
                static FixedAlloc alloc;
        };
        
        //advanced_foo.cpp
        FixedAlloc Foo::alloc(sizeof(Foo),64);
        void* Foo::operator new(size_t size){
            if(sizeof(Foo)!=size)
                return ::operator new(size);
            return alloc.allocate();
        }
        void Foo::operator delete(void*p,size_t size){
            if(p==nullptr)//C++保证删除null指针永远安全
                return;
            if(size!=sizeof(Foo)){//谁分配,谁归还(如果是global operator new分配的内存就应该由global operator delete归还)
                ::operator delete(p);
                return;
            }
            return alloc.deallocate(p);
        }
    

      哇,果然设计上干净了跟多。具体的类只需要关心自己的要做什么就行了,内存管理的事已经交由alloc来做了。
      做了一个简单测试,结果如下:

        ==4814== Memcheck, a memory error detector
        ==4814== Copyright (C) 2002-2012, and GNU GPL'd, by Julian Seward et al.
        ==4814== Using Valgrind-3.8.1 and LibVEX; rerun with -h for copyright info
        ==4814== Command: ./main
        ==4814== 
        Begin clock=660000 End clock=1370000 每次创建1000个Foo对象,然后删除,循环1000次,使用内存分配器的情况下,使用了 710000clocks.
        Begin clock=1380000 End clock=4370000 每次创建1000个Foo对象,然后删除,循环1000次,不使用内存分配器的情况下,使用了 2990000clocks.
        ==4814== 
        ==4814== HEAP SUMMARY:
        ==4814==     in use at exit: 0 bytes in 0 blocks
        ==4814==   total heap usage: 1,000,016 allocs, 1,000,016 frees, 4,004,160 bytes allocated
        ==4814== 
        ==4814== All heap blocks were freed -- no leaks are possible
        ==4814== 
        ==4814== For counts of detected and suppressed errors, rerun with: -v
        ==4814== ERROR SUMMARY: 0 errors from 0 contexts (suppressed: 0 from 0) 
    

      从上面的测试结果来看,首先没有内存泄露,程序总是应该先写对再去考虑性能的。。。其次,选用了一个简单的用例,从输出的两行来看,内存分配器还是起作用了,效果还行,更进一步的性能测试我就不做了。

    6. 总结

      这篇博客利用上一篇博客-原语篇讲到的重载member operator new/delete为特定类实现了内存管理,先给出了一个简单的per-class allocation的实现,然后一步步做了一些改进,最后实现了一个简单的内存池:固定大小的内存分配器。

    ]]>
    C++内存管理:原语篇 2014-09-08T00:00:00+00:00 Weiqing Hao http://Haoson.github.io/memory-primitives
  • 1. 说明
  • 2. 应用程序和内存管理的关系
  • 3. new/delete 底层实现
  • 4. operator new/operator delete底层实现
  • 5. placement new/delete底层实现
  • 6. array new/delete底层实现
  • 7. 其他
  • 8. 总结
  • 9. 链接
  • 1. 说明

      C++内存管理:原语篇是C++内存管理系列第一篇,主要讲C++中的new/delete expression、operator new/delete、array new/delete以及placement new/delete.文章中涉及到的部分的C++ 标准库源码都是来源于libstdc++-v3. 

    2. 应用程序和内存管理的关系

      如下图,应用程序可以直接调用C++标准库提供的分配器分配内存(一般容器使用),可以直接使用new/array new等基本原语分配内存,也可以调用C Runtime Library提供的malloc分配内存,甚至可以直接调用操作系统提供的系统调用分配内存。图中的每一层都有一个指向下一层的指针,表示上层一般是调用下一层来实现。 应用程序与内存管理的关系图

    3. new/delete 底层实现

      new/delete操作符属于语言内置,不能重载。当我们调用如下代码时:

        Complex* cptr = new Complex(1,2);
    

      编译器实际上将我们的代码转化为:

        Complex* cptr;
        try{
            void* mem = ::operator new(sizeof(Complex));
            cptr = static_cast<Complex*>(mem);
            cptr->Complex::Complex(1,2);
        }catch(std::bad_alloc){
            //内存分配失败的时候将不执行构造函数,直接抛出异常
        }
    

      也就是说,调用new的时候,由三步组成,一是调用operator new分配内存,第二是编译器对指针做转型,第三是调用构造函数。
      对于delete操作符,当我们调用如下代码时:

        Complex* cptr  = new Complex(1,2);
        ...//do something
        delete cptr;
    

      编译器实际上将我们的代码转化为:

        cptr->~Complex();
        ::operator delete(cptr);
    

      也就是说,调用delete的时候,由两步组成,先调用析构函数,然后释放内存。
      按照上面的叙述,做如下实验验证。

        #include<iostream>
        using namespace std;
        struct A{
            int a;
            A(int data):a(data){
                cout<<"A constructor.this pointer="<<this<<endl;
            }
            ~A(){
            cout<<"A destructor.this pointer="<<this<<endl;
            }
            void print(){
            cout<<"a="<<a<<endl;
            }
        };
        int main(int argc,char *argv[]){
            void* mem = ::operator new(sizeof(A));
            cout<<"mem address="<<mem<<endl;
            A* ptr = static_cast<A*>(mem);
            ptr->A(10);//在vs2013中可以通过编译,gcc中这一句不能通过编译
            A::A(9);//构造了一个临时变量
            ptr->print();
            ptr->~A();
            ::operator delete(ptr);
            return 0;
        }
    

      程序输出如下(vs2013环境下):

        mem address=0x8739008
        A constructor.this pointer=0x8739008
        A constructor.this pointer=0xbfda7850
        A destructor.this pointer=0xbfda7850
        a=10
        A destructor.this pointer=0x8739008
    

      可以看出,operator new分配的那一块内存的起始地址就是this指针的地址。A::A(9);是在栈上分配空间构造了一个临时变量,这个临时变量的生命周期存在于这一个语句,所以程序输出中的第三四句打印了构造和析构两条语句。

    4. operator new/operator delete底层实现

      new/delete expression分配/释放内存实际上调用operator new/operator delete完成,new/delete是一个表达式,显然不能重载,operator new/operator delete是函数,所以我们可以重载operator new/operator delete。对于operator new/delete来说,重载有两种形式,一是全局重载,二是类重载,一般来说,不会重载全局的operator new/delete,因为全局的operator new/delete只是简单的malloc/free,代码如下:

        operator new (std::size_t sz) _GLIBCXX_THROW (std::bad_alloc){
            void *p;
            /* malloc (0) is unpredictable; avoid it.  */
            if (sz == 0)//c++标准要求,即使在请求分配0字节内存时,operator new也要返回一个合法指针
                sz = 1;
        
            while (__builtin_expect ((p = malloc (sz)) == 0, false)){
                new_handler handler = std::get_new_handler ();//分配不成功,找出当前出错处理函数
                if (! handler)//如果用户没有定义内存分配错误处理函数,直接抛出bad_alloc异常,否则进入错误处理函数
                    _GLIBCXX_THROW_OR_ABORT(bad_alloc());
                handler ();
            }
            return p;
        } 
        void operator delete(void* ptr) _NOEXCEPT{
            if (ptr)
                ::free(ptr);
        }
    

      从上述代码中可以看到,operator delete实际上只是简单的调用CRT 提供的free来释放内存,operator new调用malloc来分配内存,但是operator new会不只一次地尝试着去分配内存,它要在每次失败后调用出错处理函数(如果用户设置了错误处理函数),还期望出错处理函数能想办法释放别处的内存。只有在指向出错处理函数的指针为空的情况下,operator new才抛出异常。那么首先看看这个handler怎么设置。直接上源码:

        typedef void (*new_handler)();
        //Takes a replacement handler as the argument, returns the previous handler.
        new_handler set_new_handler(new_handler) throw();
        //Return the current new handler.
        new_handler get_new_handler() noexcept;
    

      set_new_handler函数接收一个参数为空返回值为空的函数指针为参数,返回一个参数为空返回值为空的函数指针,对于一个设计良好的new handler,最好做到下列事情之一1

    • 让更多的memory可用
    • 安装另一个new handler
    • 丢出一个exception,类型为bad_alloc或者其衍生类型
    • 直接调用abort或者exit

      缺省的operator new和operator delete具有非常好的通用性,它的这种灵活性也使得在某些特定的场合下,可以进一步改善它的性能。尤其在那些需要动态分配大量的但很小的对象的应用程序里,情况更是如此。所以从效率上来讲,class可以接管内存管理责任,对operator new进行类重载。
      如果对operator new进行了类重载,也要对operator delete也要进行重载,关于这点,可以参见侯捷老师翻译的《Effective C++》第十条。
      关于如何对operator new/delete进行类重载,下一篇内存管理:自己动手写内存池会详细写到,这里暂时不详述。这里提一个小细节,类重载operator new/delete函数的时候,重载函数应该是static类型,因为当我们new一个对象的时候,根据new的底层实现可知,new第一步其实是调用了operator new,那么当我们重载了operator new,第一步就会调用我们重载的operator new函数,此时对象还没有分配内存以及初始化,也就是说对象此时还不存在,所以也不存在利用对象来调用函数,只能通过类调用,所以operator new重载函数应该是static类型。不过很多时候,我们知道static可以不写,这是因为编译器帮我们做了优化,你不写编译器就帮你加上,不过我们心里应该清楚这里是需要加上static的。

    5. placement new/delete底层实现

      placement new是重载operator new的一个标准、全局的版本,它不能被自定义的版本代替(不像普通的operator new和operator delete能够被替换成用户自定义的版本)。看一下placement new的源码如下:

        // Default placement versions of operator new.
        inline void* operator new(std::size_t, void* __p) _GLIBCXX_USE_NOEXCEPT{ return __p; }
        inline void* operator new[](std::size_t, void* __p) _GLIBCXX_USE_NOEXCEPT{ return __p; }
        // Default placement versions of operator delete.
        inline void operator delete  (void*, void*) _GLIBCXX_USE_NOEXCEPT { }
        inline void operator delete[](void*, void*) _GLIBCXX_USE_NOEXCEPT { } 
    

      placement new可以实现在ptr所指地址上构建一个对象(通过调用其构造函数),这里的ptr可以指向动态分配的堆上内存,也可以指向栈上内存。一般而言placement new使用场景非常少,它的最简单应用就是可以将对象放置在内存中的特定位置,示例代码如下:

        #include <new>        // Must #include this to use "placement new"
        #include "Fred.h"     // Declaration of class Fred
        void someCode(){
            char memory[sizeof(Fred)];
            Fred* f = new(memory) Fred();
            //do something
            f->~Fred();   // 显示调用对象的析构函数
        } 
    

      一般来说,我们应该尽可能不使用placement new,除非我们真的很关心创建的对象在内存中的位置,for example,when your hardware has a memory-mapped I/O timer device, and you want to place a Clock object at that memory location2。还有,我们使用placement new构造对象,当对象调用结束后,我们需要显示的调用对象的析构函数。最最重要的是,一旦我们使用operator new之后,也就意味着我们接管了传给placement new的指针所指的内存区块的职责,这里有两个职责,一是内存区域是否足够容纳对象;二是如果区域是否满足字节对齐(如果对象需要字节对齐)。
      还要注意的是,在C++标准中,对于placement operator new []有如下的说明: placement operator new[] needs implementation-defined amount of additional storage to save a size of array. 所以我们必须申请比原始对象大小多出sizeof(int)个字节来存放对象的个数,或者说数组的大小。

    6. array new/delete底层实现

      当使用new分配数组的时候,原理基本同new操作符,不过分配内存是调用operator new[](即array new),array new能被重载,分配完内存之后,在数组里的每一个对象的构造函数都会被调用。这里唯一需要注意的是编译器只会调用无参的构造函数,这里无法通过参数给予对象初值。
      当delete操作符用于数组时,它为每个数组元素调用析构函数,然后调用operator delete[](即array delete)来释放内存。array delete同样能被重载。
      对于array new/delete,源码如下:

        void* operator new[] (std::size_t sz) _GLIBCXX_THROW (std::bad_alloc){
            return ::operator new(sz);
        }
        void operator delete[] (void* ptr) _NOEXCEPT{
            ::operator delete (ptr);
        }
    

      可以看到,array new/delete底层实质上还是调用operator new/delete来分配空间。
      这里提一个小细节,如果我们new数组之后使用delete ptr;(没有那个中括号)会发生什么呢?从上面的源代码可以看到,array delete底层还是调用::operator delete来释放内存的,参数是指向将要释放的区块的开始地址,所以分配的内存一定是会正确释放的,不可能有内存泄露。前面提到delete数组时候,第一步是为每个数组元素调用析构函数,如果我们delete数组时候忘记写中括号,那么编译器认为这是一个对象,编译器就会调用第一个元素的析构元素,其余元素的析构函数将不会被调用。

    7. 其他

      第一点,前面提到重载placement new时可能遇到alignment问题,这里强调一下,C++标准里要求了所有的operator new(placement new是重载operator new的一个标准、全局的版本)返回的指针都有适当的对齐(取决于数据类型)。malloc就是在这样的要求下工作,所以令operator new返回一个得自malloc的指针是安全的,那么什么时候会遇到alignment问题呢?举个例子,直接上代码:

        static const int cooike = 0xABABABAB;
        void* operator new(size_t size)throw(bad_alloc){
            size_t real_size = size+2*sizeof(int);
            void* mem = malloc(real_size);
            if(!mem)
                throw bad_alloc();
            //将cookie写入到区块的最前面和最后面
            *(static_cast<int*>(mem)) = cooike;
            *(reinterpret_cast<int*>(static_cast<unsigned char*>(mem)+real_size-sizeof(int))) = cooike;
            //返回指针,指向位于第一个cookie之后的内存位置
            return static_cast<unsigned char*>(mem)+sizeof(int);
        }
    

    可以看到上述代码返回的其实并不是一个得自malloc的指针,这个指针可能就是一个没有适当对齐的指针。所以重载operator new(包括placement new)都需要格外小心。
      第二点,从上面的介绍可知,new/delete,operator new/delete或者array new/delete等实质上都是调用malloc/free来分配/释放空间,对于malloc和free的底层实现,后续文章再做分析。
      第三点,一个小细节,释放空间的时候,我们注意到free只需要一个指针参数,指向要释放的区块的开始地址,那么释放空间的时候怎么知道释放的空间大小呢?这里简单说一下,分配内存的时候,会记录一个cooike,cooike记录了分配内存的大小,一般来说,这个cookie放在区块开始地址的”上面”,释放的时候只需要根据开始地址再向上找4个字节就能得到区块大小…

    8. 总结

      这篇博客主要介绍了C++内存分配中的一些基本原语的底层实现和一些要注意的点,下一篇我将利用这些原语实现一个自己的内存池。

    9. 链接

    1. 侯捷-《Effective C++》第三版

    2. What is “placement new” and why would I use it?

    ]]>