std::vector的错误使用

上周五服务器线上出现了几次crash,拿回dump文件分析后发现代码是崩在了对一个引用的成员变量赋值上。

分析了半天也没看出来代码有什么不妥,就先搁置了。

今天同事又给我看了一段奇怪的代码,某个类成员函数返回了某个成员变量的引用,但是当指针为NULL时去调这个函数依然不会崩溃。

思来想去搞不明白,反汇编之后终于发现原因所在。

c++引用本质上也是指针,只是不能为NULL而已。因此返回引用其实就是返回这个变量的内存地址。也就是说这个函数实际的操作仅仅是拿this指针加上这个成员变量的偏移量,然后将结果返回给引用变量。这个操作从始至终都没有没有去操作内存,当然也不会崩溃。

解决了这个疑问之后,又想起来上周五的崩溃。再次分析了一下代码,想看看是否是因为相同的问题引起的。

花了两个小时之后终于发现,其实是误用vector引起的。

这段代码的作者使用vector实现了一个结构体池,每次申请结构体时从池中获取,释放时归还到池中。

结构体池的定义类似std::vector<struct xxx> pool;

之所以产生bug是因为,每次当vector中的元素被使用完之后,都会掉用resize来将vector的容量加倍。

熟悉vector的人都知道,vector本质上就是一个数组,当大小不够时就重新分配一块更大的内存并释放掉原先的旧内存。这会导致vector中元素的内存地址全部改变。

在调用池的分配函数时,已经把相应元素的内存地址返回给了逻辑代码。vector内存地址的改变势必会导致在操作以前分配出去的元素时会出现访问错误内存。

bug正是这样产生的,函数a从池中申请了一个元素,然后掉用了函数b。函数b又从池中分配了一个元素,恰好池中元素用完了,导致了vector进行resize。当返回到函数a时,在对以前返回的元素进行操作实际上是非法的。因为这块内存已经被释放掉了,而相关数据也已经被挪到新内存了。

tcp使用的进一步了解

以前对socket的了解仅仅局限于listen/connect/epoll/select/close等这些API的表面使用。其具体语义以及一些状态都没有深究。总觉得这样写代码会出问题,今天咬咬牙把《tcp协议卷1》中的Tcp部分又看了一遍。发现由于对协议和API语义的了解不足,在程序中还是犯了不少错误。

TIME_WAIT状态是tcp网络编程中最不容易理解的地方。主动关闭的一方会经历TIME_WAIT状态, 这个状态会经历2MSL的时间。其目的就是为了可靠的实现TCP全双工连接的终止和允许老的重复分组消逝在网络中(具体可以参看UNP P37)。

TIME_WAIT的持续时间一般是1分钟到4分钟,如果在这段时间内涌入大量连接,然后服务器将其断开,就会导致在服务器上残留大量处理TIME_WAIT的连接,理论上这会严重拖慢系统的性能。

因此,只要条件允许,应该尽量让客户端来主动断开连接。

由于服务器绑定的是固定端口,当重启服务器时,只要还存在有通过这个固定端口接入进来的TIME_WAIT状态的连接,就会导致bind失败。因此服务器一定要为bind的socket设置SO_REUSEADDR属性。


close的默认行为首先对描述符引用计数减一,如果引用计数为为0则执行close流程。即把该socket标记为关闭, 然后立即返回到调用进程,该socket不能再被调用进程使用。然而tcp将尝试发送已经排队等待发送到对端的任何数据,发送完闭后才会执行正常的tcp连接终止序列(即四次挥手). 这个行为可以通过SO_LINGER更改。

相比close, shutdown的行为则更为粗爆一些。

不管有多少进程在持有这个socket,一旦这个socket被shutdown, 那么所有的进程均不能再对此socket进行已经shutdown过的操作。shutdown可以分别关闭读和写。

shutdown写时会将当前发送缓冲区中的数据都发送掉才会进行连接终止序列。
shutdown读时会将当前接收缓冲区中的数据都请空。

在silly的实现中,我对于close的实现仅仅是粗爆的将所有发送缓冲区清空,然后关闭socket。对比系统的close和shutdown函数可以发现, 这样做是不对的,因为在应用层调用close时,有可能数据并没有发完, 这样就会有可能导致客户端接收到的信息不完成,而造成其他bug.

seqlock和rwlock

最近看《linux内核设计与实现》时看到一种叫seqlock的锁,其作用与rwlock有相仿的功能。

在读写频率不对等的情况下,使用普通的互斥锁显然并不是一个好主意。

由于读取内存中的数据并不会产生副作用(嵌入式除外),因此在数据不改变的情况下,多个读操作可以并发执行。

在读频率远大于写频率时,如果仅仅将写操作与读操作互斥,读与读之间并发执行,显然可以大幅提高程序的性能。

所以在这个时候,读写锁应运而生。使用读写锁时,多个读操作可以并发进行,但是只要有读操作在进行,写操作就必须等待。

其实现原理并不复杂。

获取读锁前检查是否写锁已经被获取,如果获取则等待。如果写锁没有被获取,则对读者数加一。释放读锁时对读者数减一即可。

获取写锁时,直到读者数为0才可以将写锁置为获取状态。

以前也写过一个类似的rwlock, 并在此基础上增加了写与写互斥和写操作防饿死机制(为了支持多线程并发写)。


从读写锁的实现上来看,读写锁对读比较优待,只要有读操作就写操作就必须等待。

如果某块内存仅有一个线程很频繁的写,其他线程只是偶尔读一下,并且要对写非常优待,即有线程的写操作不能被打断。

这时候seqlock就派上用场了。

seqlock的实现更为简单。

每一个seqlock维护一个index索引值,每次向seqlock获取写锁时总能成功,并把seqlock的index自增。

每次读操作前后都获取一下seqlock的index索引值,如果前后获取的index值并不相同,则重新读取数据。

从上面机制可以看出,与rwlock相似都是对某一频繁操作进行优待,但与rwlock不同的是,seqlock是对写操作优待。

lua的debug_hook接口应用

lua本身并没有提供调试器, 但是提供了一组称为debug_hook的API, 这组API可以用来编写自己的调试器或者一些其他的东西。

这两天的工作主要就是研究了一下debug_hook这组接口, 写了一个极简化的lua调试器和一个lua性能分析器.


lua调试器现在仅支持break point, step, print, coutinue这几个功能。

由于最近写lua已经习惯于不用调试器了, 因为实现的比较简单。

下断点时, 将要断点所在的文件与行号存入表中。

在不考虑效率的前提下, 直接用line mode来hook中每一行的运行,然后在每个line_hook函数中去check当前文件名和行号是否与断点信息中进行匹配, 如果匹配则进入调试模式。

在调试模式下, 可以step/print/continue来控制程序做单步/打印变量/继续运行等操作。

在打印变量时, 首先搜寻局变量是否存在此变量, 如果没有则搜寻上值中是否存在些变量, 如果都没有则提示没有此变量。

然而,作为一个能用的调试器来说, 这个调试器还缺的太多。如:打印表中内容, 修改变量, 支持call stack的切换, 非法断点提示, 运行到指定行, 打印堆栈回溯信息等。

由于平时写代码不太依赖调试器,所以暂时不打算继续完善。这里先把思路记述一下,有需要再继续完善。

在简化版本实现中,仅支持打印如integer/string 这样的非table数据, 如果有table数据的话则只打印一个指针。

可以增加对变量类型的判断,如果是table则递归打印表中内容, 这里可能存在的一个问题是直接去访问一个表会触发__index元方法, 因此一定要根据具体情况考虑是否使用rawget。

修改变量与打印变量类似,修改可能会触发元方法__newindex,因此要具体考虑是否使用rawset来修改。

将一个debug模式下手动输入的表赋于某个变量时, 为了更方便输入, 可以支持将表以字符串的形式输入,然后内部使用load来执行这段字符串,然后将产生的表赋于某个变量。

在call stack切换时使用lua_getstack切换到相应的call stack中,然后调用lua_getinfo就可以得到当前的stack中相应的环境。只是这里仅能支持print变量功能。

如果要增加非法断点提示就比较麻烦了,初步的想法是,为每一个文件维护一个可断行号表, 在对某个文件第一次下断点时,过滤掉这个文件的空白行和注释行, 然后把有效行置入可断行号表,再去查询要下的断点是否是有效行。如果不是有效行, 则可以根据情况进行提示, 或向上/向下偏移断点行号。

运行到指定行则比较简单, 下一个临时断点,到达指定行后删除此断点即可。

打印堆栈回溯信息直接使用luaL_traceback函数即可。

最后就是关于调试器效率的优化了。

直接设置line hook效率比较低,可以首先设置为call hook,每次call hook时判断当前函数是否有被下过断点。如果有断点则设置为line hook。每次进入line hook时除了要判断是否命中断点外,还需要判断当前函数中的断点是否已经全部步过,如果所有断点全部步过,将hook设置为call hook。

由于lua有tailcall支持,因此上述优化在某些情况下并不一定能如想象中的那么顺利, 只有等到实际优化时碰到问题再来解决。


相比极其简单的lua调试器来讲,lua性能分析器我花了将近一天的时间来写。

在linux下有gprof可以在很方便的测度整个程序的性能热点, 在windows下VS自带的性能分析器也非常强大。

但是在写lua想测试性能时,要测哪一个函数的时间开销就得去哪一个函数定义处加时间测量代码。

我希望实现一个非侵入式lua profiler,就像gprof一样。

在《Programming in lua》一书中讲解debug_hook时,演示了一下怎么使用debug_hook(call/ret)来测试函数的调用次数,这给了我很大的启发。

我希望这个profiler可以即统计调用次数,又统计时间开销,。

由于lua自带的debug lua接口时间开销很大,想要精确统计出函数的运行时间就必须用debug_hook的C接口来实现。

大概实现如下:
每一个函数单独维护一张表, 表中存有进入此函数的当前cpu时间, 此函数上次ret时已经运行了多少时间,当函数已经被调用了多少次。

设置call hook和ret hook, 在call hook时更新函数的调用次数及进入此函数的cpu时间,在ret hook时根据当前cpu时间和进入此函数的cpu时间即可更新此函数已经运行的总时间。

实现完之后,放到silly中去用即刻就发现问题了。没错是coroutine和闭包这些高级特性。 silly是用大量coroutine和闭包组成的一整套框架,如果不解决coroutine和闭包在profile中的影响,那这个profiler几乎没法使用。

在《Programming in lua》中, 作者使用函数的指针来作为profile数据的索引, 但是在碰到闭包之后, 这种方法就会显得有些麻烦了。

假如有一个函数A可以生成另一个函数, 如果调用A两次分别生成函数B1和B2, 那么B1和B2的函数地址是不同的,在做profile时表中就会有两个此函数的信息, 但是其实两个函数的定义是一模一样的。而且由于使用了函数指针作为索引,为了不影响被分析代码的GC形为,必须使用weak-table来cache住这些闭包的性能数据。但是闭包函数的生命周期一般较短(这取决于代码作者),因此有可能来不及打印此函数的性能数据,此项数据就被GC干掉了。

为了解决这个问题,我不再使用函数指针作为profile数据的索引,而使用“文件名:行号”来做为函数性能分析数据的索引,这样不管有多少个相同函数定义的闭包,其执行时间都是算到同一个函数定义的性能数据上。

coroutine可以使用yield/resume来让出/恢复运行,但是显然从让出到运行这断时间并不能算在这个函数的执行时间开销上, 所以怎么跳过这断开销非常重要。

想要解决这个问题只能在coroutine.yield和coroutine.resume这两个api上做文章。

由于在C代码中调用yield之后下次再被resume时,会直接走到lua代码中,并不会回到上次调用yield的C代码处。为此我不得不使用lua对C接口实现的lprofiler做了一个wrapper。

在lua实现的profiler.start代码中将coroutine.yield和coroutine.resume备份并换成自定义yield/resume函数。
在执行自定义yiled/resume之前调用lprofiler.yield,更新当前call stack上所有函数的运行总时间。在执行自定义yield/resume之后调用lprofiler.resume来更新当前call stack上的所有函数开始执行的cpu时间。

如果多个coroutine共用一个函数,那么每一个函数定义对应于一个性能分析数据就不能再满足需求了.
如co1和co2共用一个函数, 当co1执行到50%时yield让出,如果co2接着执行就会再次时入此函数的call hook, 会重新刷新此函数的函数进入时间,这样当co1执行完成之后,得到的时间开销仅是实际开销的一半。

为了避免这个问题,也为了可以针对某一个具体coroutine进行profile。

可以为每一个coroutine单独存储每个函数的性能分析数据。 由于使用coroutine来做为索引, 因此一定要使用weak-table来存储与coroutine相关的性能分析数据。

在打印性能分析报告时,可根据实际情况,合并所有coroutine中的相同函数定义的时间开销。

由于coroutine一旦被回收其性能分析数据均会被GC掉,因此打印性能分析报告的时机一定要选择好。

如果最终确定不需要对单个coroutine进行profile,可以仅仅为每个coroutine维护所执行函数的开始执行cpu时间。这样即可避免当coroutine被GC后,相关的profile数据丢失问题。