一例误用unsafe包引起的内存问题

Go语言的指针真的很灵活,远非其他带GC的语言可以比拟的。

比如下面这段代码,并不会产生GC问题。

而在其他带GC的语言中,是不太可能写出类似这种代码的。

var b *int
{
    a := make([]int, 3)
    b = &a[1]
}
fmt.Println(*b)

在写Go代码时,常常会让人有种在写C语言的错觉,他的指针几乎实现了90%的功能,剩下没有实现的10%则是与GC有关。

而unsafe包则可以让我们做到剩下的10%, 但是内存安全性需要我们自己保证。

比如下面代码就是完全正确的,只要c还活着,a就不可能被回收。

var b unsafe.Pointer
{
    a := make([]int, 3)
    a[1] = 0x01020304
    b = unsafe.Pointer(&a[1])
}
c := (*byte)(b)
fmt.Printf("%04x\n", *c)

在一些性能敏感的地方,比如在一场回合制战斗或者一个复杂对象的反序列化中,内存分配可能会吃掉相当一部分算力。

这些场景都有一个共同的特别,有一大批小对象同生共死。如果能够将这些小对象分配进行批量化,就可以显著提高性能。

比如下面代码,性能会有将近300%的差别。

package main

import (
    "os"
    "unsafe"
)

type Foo struct {
    a uint64
}
var pool []uint64
//go:noinline
func Alloc(direct bool) *Foo {
    if direct {
        return &Foo{}
    } else {
        var f *Foo
        size := unsafe.Sizeof(*f)
        need := (size + 7) / 8
        if len(pool) < int(need) {
            pool = make([]uint64, 512)
        }
        p := unsafe.Pointer(&pool[0])
        pool = pool[need:]
        return (*Foo)(p)
    }
}

func main() {
    direct := len(os.Args) == 1
    for i := 0; i < 64*1024*1024; i++ {
        Alloc(direct)
    }
}
/*
$ time ./a
./a  0.77s user 0.01s system 110% cpu 0.710 total
$ time ./a x
./a x  0.26s user 0.06s system 129% cpu 0.250 total
*/

不用怀疑,上面的代码一定是对的。

因为所有使用Alloc分配出来的指针一定是8字节对齐的,而所有的Foo指针也必将引用pool对象的内存,使他不被回收。

然而,有朝一日,代码被迭代成了下面的样子,GC就会开始紊乱了。

package main

import (
    "os"
    "unsafe"
)

type Bar struct {
    b uint64
}

type Foo struct {
    a uint64
    b *Bar
}

var pool []uint64

//go:noinline
func Alloc(direct bool) *Foo {
    if direct {
        return &Foo{}
    } else {
        var f *Foo
        size := unsafe.Sizeof(*f)
        need := (size + 7) / 8
        if len(pool) < int(need) {
            pool = make([]uint64, 512)
        }
        p := unsafe.Pointer(&pool[0])
        pool = pool[need:]
        return (*Foo)(p)
    }
}

func main() {
    direct := len(os.Args) == 1
    for i := 0; i < 64*1024*1024; i++ {
        f := Alloc(direct)
        f.b = &Bar{}
        //do something with f
    }
}

让我们来写段代码测试一下:

package main

import (
    "fmt"
    "runtime"
    "time"
    "unsafe"
)

type Bar struct {
    b [64]int
}

type Foo struct {
    a uint64
    b *Bar
}

var pool []uint64

//go:noinline
func Alloc(direct bool) *Foo {
    if direct {
        return &Foo{}
    } else {
        var f *Foo
        size := unsafe.Sizeof(*f)
        need := (size + 7) / 8
        if len(pool) < int(need) {
            pool = make([]uint64, 512)
        }
        p := unsafe.Pointer(&pool[0])
        pool = pool[need:]
        return (*Foo)(p)
    }
}

func fin(r *Bar) {
    fmt.Printf("fin %p\n", r)
}

func main() {
    f := Alloc(false)
    f.b = &Bar{}
    runtime.SetFinalizer(f.b, fin)
    fmt.Printf("%p %d\n", f.b, unsafe.Sizeof(*f.b))
    time.Sleep(time.Second)
    runtime.GC()
    time.Sleep(time.Second)
    fmt.Printf("%p %d\n", f.b, f.b.b[0])
}

在第50行代码,我特意加上了fmt.Print来引用f对象,甚至引用了f.b对象。

但是我们可以从log看出,在48行GC时,f.b指向的内存已经被回收了。

之所以会出现这种情况,本质上和Go语言的GC机制有关系。

在上一篇文章中,我找到了一些资料来解释为什么Go语言的指针可以有这么大的自由度。

现在这篇文章同样能解释这次的Bug。

本质上在Go语言代码层面,所有的指针和unsafe.Pointer的功能并无两样,仅用于指明这个变量是指针,在进行GC Mark时,需要Mark这个变量所指向的内存块。

至于这块内存中指针变量的位置,是在new这块内存时,Go编译器会根据这块内存的类型来标记的。

我们上面的Alloc池,在分配内存时给GC的信息是,我们要分配一个512大小的uint64类型的slice。

编译器生成代码时就会标明,这块内存中没有指针成员,在GC Mark时不需要继续Mark子元素。

虽然我们将其中的某块内存使用unsafe包强转成Foo对象的指针,但这也仅能保证pool对象内存的安全,并不能保证Foo对象中指针变量指向内存的安全。

GC系统从Foo的指针最终是Mark的是pool对象,当然也就不能Mark到Foo.b所指向的内存。

在这段代码中,还有一个有意思的现象,即使第50行我们引用了f.b对象。但是依然不能阻止GC对他的回收。

但是我们在47行之后,如果插入一行b := f.b就可以阻止f.b对象被回收。

这是因为,虽然Go的最新版GC使用了混合写屏障,但是在一个GC循环中,每个goroutine的栈至少会被扫描一遍的。

GC不在运行中,代码44行的混合写屏障没能开启,所以f.b没能被mark。

GC运行时扫描栈对象时,又识别不出f.b变量是个指针,更不可能Mark内存块f.b。

内测过程中Shader出现的问题

兜兜转转一年多, 终于再次内测了。

这次在客户端开发中,我们的指导思想是能用GPU做的坚决不用CPU做,除非GPU出现了瓶颈。因此我们大量使用了自定义Shader。

由于我之前其实没有太多Shader的编写经验,这次上线之后暴露了不少实践性问题。


首先遇到的就是精度问题。

在地表渲染过程中, 如果碰到下雨天,我们会在地面湿滑到一定程度之后生成涟漪。

这个功能是直接做在地形Shader中的,与涟漪Bug相关的代码如下:

//ripple.a = 0.4117647
float f1 = frac(ripple.a + _Time.y);

上线之后,我们发现在小米系列手机上,当_Time.y的值大于300之后, f1的值会产生跳变。

经过抓帧之后发现。

_Time.y``300.033``f`等于`0.5019608`, 此时`f`的正确值应该是`0.4447647

_Time.y``300.066`时,`f`的值还是等于`0.5019608`, 此时`f`的正确值应该是`0.4777647

将代码改为如下:

//ripple.a = 0.4117647
float f1 = frac(ripple.a + frac(_Time.y));

_Time.y``300.033``300.066`时,f1的值分别为`0.4431373``0.4784314

与正确值相比,误差分别是0.0016274``0.0006667

这些数值是通过颜色调试法取得,而像素的颜色精度只有1/255(0.0039216), 因此可以认为误差是颜色调试法带来的,而整个计算是精准的。

这说明了高通系列的GPU,其float在计算过程中,要比IEEE 754标准的浮点型精度更低,可能远小于7位有效数字。

这也给我提了一个醒,当我们的Shader需要长时间运行时,一定要注意_Time.y过大之后,在运算过程中会精度丢失的问题。即使GPU完全按照IEEE 754标准来实现,只要运行的时间足够久,也会出现这个问题(比如我们的树,在所有客户端上,只要运行超过4个小时之后,就会静止不动)。

有些情况下,不是简单加一个frac函数就能解决问题的。这时,就需要将与_Time.y相关的数值移到C#中去计算,然后在每一帧的Update中,向Shader设置变量,这么做会有一个额外好处,可以将对_Time.y相关的计算减少到每帧一次。如果在shader中计算_Time.y相关的逻辑,则每一个顶点或像素都需要重新计算一次。


另外一个Bug还是与精度有关,不过是以另一种方式存在。

在世界地图中,如果玩家立国,需要将国家的颜色铺满整个行省,而行省的形状是异形的,如果使用Quad的方式去铺满整个地图,会带来大量的Overdraw。

因此在实现过程中,我们给整个大地图设计了一张IDMap, 每一个像素都会有一个整数ID来代表他所在的行省。

在FragmentShader中,我们采样IDMap之后,并不直接用于渲染,而是将他转换成整数ID,然后使用ID来当索引查询当前行省的颜色。将查询到的颜色用于渲染。

大概代码如下:

fixed4 frag (v2f i) : SV_Target
{
    fixed4 c = tex2D(_MainTex, i.uv);
    int n = clamp(c.a * 255, 0.0, 45.0);
    return _Colors[n];
}

上线之后,我们发现在华为系列手机,这个n会有偏差(安卓系统和鸿蒙系统表现还不太一样),但是在国内其他主流手机,如小米,Oppo上不会出现。

在问题排查过程中,我一度怀疑是精度问题。因此不停地在图片格式上做文章。直到最后我才发现我犯了一些常识性错误。

首先,RGBA32格式的图片是指RGBA的4个通道分别占用一个byte(8bit)来表示一个通道颜色值。

图片文件中,实际存储的颜色值是0~255的整型,而不是0~1的浮点型,也就是说单通道精度最高也只能到1/255。

而我们实际使用过程中n的值只是0~45,远低于1/255,不可能是图片精度问题。

其次,在计算过程中 1/255*255 `的结果实际上并不是`1`而是`0.99999999999975左右。

在Intel、AMD、高通系列芯片上,int a = (int)(1.0 / 255.0 * 255.0), a是会等于1的。

在麒麟系列芯片,a则会等于0,我不能说麒麟系列芯片的精度够或是不够,只能说我写的代码不规范。

这次的教训告诉我,浮点型在不同平台的实现过程中,会有平台相关性。

定位到了问题,修复自然就是一件很简单的事。

int n = clamp(round(c.a * 255), 0.0, 45.0);

或者

int n = clamp(c.a * 255 + 0.0000001, 0.0, 45.0);

都可以解决问题。

一次并发Bug

最近碰到一个bug, 约7天发生一次,历时3周才找到问题并修复。

在找到bug并修复后,我仔细反思了一下。

我发现不仅这个bug是并发bug的一个典型,修复bug的过程也是直击我的弱点。

下面记录一下整个过程。

最近突然想对我的网络框架做一次RPC浸泡测试。

写了一段代码,开了6000个客户端,去并发RPC请求,服务端收到RPC之后会随机10~2500ms 延时之后再返回。 丢到我的VPS上之后,也没在意,过了两天就忘记这回事了。

过了大概一周,我突然想起来我还有一个浸泡测试在跑着呢,就上去看了一下log.

令我意外的是,网络和rpc没有出现bug,定时器出现Bug了。

就这样一边加log等重现,一边看代码,在历时3周后终于靠看代码修掉了。

定时器相关代码大概如下:

--core.lua(run in thread1)
function core.timeout(ms, func)
    local co = cocreate(func)
    local session = silly_timeout(ms)         --silly_timeout 等价于 silly_timer_timeout
    sleep_session_co[session] = co
    sleep_co_session[co] = session
    return session
end

local MSG = {}
function MSG.expire(session, _, _)
    local co = sleep_session_co[session]
    assert(sleep_co_session[co] == session)
    sleep_session_co[session] = nil
    sleep_co_session[co] = nil
    wakeup_co_queue[#wakeup_co_queue + 1] = co
end
//silly_timer.c
uint32_t
silly_timer_timeout(uint32_t expire)
{
    struct node *n = newnode();
    if (unlikely(n == NULL)) {
        silly_log("silly timer alloc node failed\n");
        return -1;
    }
    lock(T);
    n->expire = expire / TIMER_RESOLUTION + T->ticktime;
    assert((int32_t)(n->expire - T->expire) >= 0);
    add_node(T, n);
    unlock(T);
    return n->session;
}

// run thread2
void
silly_timer_update()
{
//检查所有struct node, 是否超时,
//如果超时,则向lua层抛出expire消息,然后将node结点Free掉
//在Lua层收到expire消息后,会调用MSG.expire函数
}

发现bug时,是发现在MSG.expire函数的第二行assert失败了。

这时我就告诉自己,柯南君的名言“排除一切不可能,剩下的再不可能也是真相”。

即然assert失败了,那只有三种可能。

A1. co 为 nil, 这时sleep_co_session[co]为nil
A2. co 不为nil 且sleep_co_session[co] 为nil
A3. co 不为nil 且 sleep_co_session[co]不等于session

我检查了一下代码,发现sleep_session_co 和sleep_co_session都是一起操作的,因此我将A2排除了。

为了进一步排除A1和A3, 我在MSG.expire加了点log,当assert失败时,打印一下session和co的值。

过了几天后确定是A1.

而导致A1产生的可能情况有以下几种可能:

B1. silly_timer_timeout返回了两个相同的session
B2. silly_timer_update 对同一个session返回了2次
B3. silly_timer_update 返回了一个陌生的session

我查了一下rpc client端的log,我发现在相同的时间确实有一次rpc请求失败了,排除了B2.

如果是情况3, 那就代表silly_timer_update时返回的是脏数据,我查看了silly_timer.c中的代码,发现node->session只有在newnode时被赋值,其他情况并没有被改动过。因此我将B3排除了。

现在就剩下B1,出现B1 有两种可能。

C1. session回绕了
C2. 在++session时有并发问题出现了某种竞争

于是,我在session回绕和session每增加1000时,分别打印了一行log。以期望可以在下次重现时,获得一些有用的信息。

再次出现之后,我发现session并没有回绕,而session每增加1000打印的log似乎对我并没有什么帮助。

我开始把矛头对准C2分析。但是我反复检查,以我现有的知识来讲,session都不可能存在并发问题。

线突然断了,我知道一定是我哪里弄错了,但是我思维已经造成了定式。反复思考也没看到哪里有问题。

之后,每天打开电脑第一件事,就是去看一遍之前发生过的log和代码,以便突然有点灵感。

在过了大约7天之后,我突然发现,session每增加1000时的log L1和assert失败时的log L2发生在同一秒,而出错的session比L1中打印出来的session要小。

这也意味在有一个session在timeout之后立即就收到了expire消息。

于是我重新去看了一下silly_timer_timeout函数,我终于发现问题所在了。

在silly_timer_timeout时由于超时时间非常短的情况下。如果有以下步骤,就是必现的。

  1. silly_timer_timeout加入node N1, 在执行完unlock之后,silly_timer_timeout所在的线程T1突然被切换而暂停执行了。
  2. 这时silly_timer_update在线程T2执行了,它检查到N1已经达到了超时标准,于是将其关联的session用expire消息推送到lua线程并将N1释放(free)。
  3. 其他代码调用malloc重新分配到了刚刚释放N1的内存,并写入数据
  4. T1 重新唤醒被执行。silly_timer_timeout返回了错误的session, 但是silly_timer_update却推送了正确的session.

我在unlock之后加入了usleep(5000)之后,不一会,bug就重现了。


我复盘了一下整个bug分析流程,我发现整个思路都没有什么大问题。

惟一有问题的是,我做了一个错误的假设B1。

B1 应该被修改为,返回了一个无效的session(重复的session同样是无效的session)。

之所以我做了错误的B1假设,是因为潜意识告诉我,silly_timer_timeout返回的session不可能问题了。我在一个错误的假设上做了各种推理。

这次经历告诉我,当相关代码出bug时,一定要忘记所有背景知识,仅对出问题的相关代码进假设(一切皆有可能)。

然后再根据相关信息进行逐个排查。

即使如此,打破思维定式,真的很难 ^_^!

移动平台native代码遭遇的坑

最近客户端终于开始运行在移动平台上了(当然,快开发完才开始在移动平台尝试运行,本身就是一件很错误的顺序,然而这并不是我所能控制的),之前在PC平台上完全没问题的代码,开始出现一些诡异的问题。

为了保证客户端和服务器使用绝对相同的逻辑执行流程,我们采用C++来开发一部分native代码同时供客户端和服务端来使用。在迁移到移动平台时,这些native库在IOS和Android平台上出现了不同程度的水土不服。

首次在移动平台就发生了crash,并且只有Android平台会crash, 而IOS可以正常进入游戏。

最后定位到,当执行类似下面的代码时安卓平台就会发生crash。

int a = 3;
char buf[64];
char *p = buf;
*p = 0;
*(int *)(p + 1) = a;

在编译安卓平台native动态库时,为了尽可能的保证兼容性,我们采用了armeabi-v7a来编译native动态库,据ARMv7开发文档显示,在ARMv7架构下,uint32_t *需要4字节对齐,而uint16_t *则需要2字节对齐,只有uint8_t *才不需要对齐约定。

而苹果自iphone5s发行时,就采用了基于ARMv8-A架构的的Apple-A7。根据ARMv8-A开发文档显示,在ARMv8-A架构下,所有地址访问都不再需要指针对齐要求。换句话说在IOS的64位平台上,上面代码是完全正确的。

当然,木桶原理,为了保证代码在所有平台上都能正常运行,需要做出如下修改:

//此段代码同时可以无视机器大小端,而强制a在内存中的布局为大端还是小端,此种写法为小端
- *(int *)(p + 1) = a;
+ p[1] = a & 0xff; 
+ p[2] = (a >> 8) & 0xff; 
+ p[3] = (a >> 16) & 0xff; 
+ p[4] = (a >> 24) & 0xff;

如果要保持与机器大小端相同可采用如下写法:

- *(int *)(p + 1) = a;
//由于a只有四个字节,此处可以手动展开memcpy以优化函数调用和for循环
+ memcpy(p, &a, sizeof(a));

看到Android这么热闹,IOS也有点不平衡,在调用某个native函数时,报出了`To marshal a managed method, please add an attribute named ‘MonoPInvokeCallback’ to the method definition.`错误。

但是并不是所有native函数都会有这个问题。经过比较发现,这个函数在设计时,为了方便方便Unity可以接管native内部的log, 多增加了一个参数,用来将C#中log函数传入。直接将参数改为NULL时,果然问题解决了。但是很奇怪的是,在Windows下并不会有此问题。

最终在MonoTouch的官方文档中找到了答案。

在微软向ECMA提出的CLI(通用语言基础架构)中,并没有定义标签`MonoPInvokeCallback`。这也正印证了,我们在PC平台上从来没有出现过此问题。

如果在编译成移动平台时’Scripting backend’选项选用了`IL2CPP`,就需要使用AOT编译器来进行编译。

而进行AOT编译时,MONO需要知道哪些静态函数可能会被native代码调用,以便对C#函数进行额外处理(ps. 理论上,一个函数是否需要会被传入native函数中,是可以在编译时推导出来的,不知道MONO为什么没有做这件事)。

GC竞争问题

阅读Lua GC源码中,我们就提到过一个细节,所有带有__gc函数的对象,在第一轮GC循环中只会执行__gc函数,直到第二轮GC才真正清除。

一直没有找到必须这样做的场景,直到最近我发生了一例GC竞争的bug之后,才恍然大悟。回过头想想,其实在我之前翻译Barry Hayes大神的一篇论文里也早都提到过,只不过当时例子是释放OS资源,而场景也太过抽象,才没有引起我的注意。下面来看一个MWE。

我已经尽可能的精简代码,然而还是需要170+LOC。

在这个例子里,实现了一个链表管理,所有的link和node结构均交由Lua GC来自动管理内存。

由于Lua GC不能分析C结构之间的引用关系,因此所有的node(userdata)必须通过一个Table来保持引用,以防GC误回收。这也是为什么在luaopen_link函数中,我们为所有函数绑定了一个相同的UpVal(Table)。

下面来分析为什么会有竞争的发生。

首先这个UpVal是全局的,也就是会与luaVM同生共死。所以`UpVal中node对象的生命周期` >= `link的生命周期`。

因此竞争问题只会出现在UpVal的生命周期与link的生命周期一同结束时。假设UpVal和link的生命周期都在GC 循环C1中结束。没有机制能保证UpVal先于或后于link死亡,因此node对象的__gc和link.free在这一周期是竞争执行的。这就是RACE1和RACE2都需要将buff置为NULL的原因,只要有一方不置为NULL另一方就有可能出现double free。

之所以置NULL不会有memory corruption问题. 是因为在Lua GC实现中,所有带__gc函数的对象,在当前GC循环死亡后并不会立即释放内存,而是会等到下一轮GC循环才会真正释放。换句话说只要在本轮GC循环中,不管什么时间访问操作node指针都是有效的。

反过来讲,由于在释放过程中可能存在竞争或释放过程中循环依赖的情况。GC模块要保证在执行__gc函数过程中,所有需要的数据都是有效的,就必须要延迟一个GC循环来回收内存。

ps.其实上面的竞争问题可以通过其他手段解决,比如为link设置一个__gc函数来释放buff, 移除掉node对象中__gc函数的释放buff行为,但是不管怎么样竞争场景确实存在,比如两个user data相互引用,并被同一个Table持有等。

又一个类型提升引起的Bug

在好几年前我已经中招过一次了, 没想到最近一不留神又中招一次。不过这次的花样和上次又不太一样。

Bug的起因是,我需要一个函数,根据指定速度(可能不是整数)和距离来获取到达目的点的时间,于是就有了下面这样一段代码。

//#define TIME time(NULL)
#define TIME 1526796356
time_t foo(int distance, float speed)
{
        return TIME + distance / speed;
}
int main()
{
        printf("%ld\n", foo(30, 3.0f));
}

咋一看这代码几乎没毛病,严格遵循牛顿大神给出来的公式来算。

然而他的输出却是`1526796416`这样一个值。在思考了将近20分钟之后,我才恍然大悟。

根据《The C Programming Language》第二版中的A6.5节算术转换一节的内容

`
First, if either operand is long double, the other is converted to long double. Otherwise, if either operand is double, the other is converted to double.

Otherwise, if either operand is float, the other is converted to float.

Otherwise, the integral promotions are performed on’ both operands; then, if either operand is unsigned long int, the other is converted to unsigned long int

Otherwise, if one operand is long int and the other is unsigned int, the effect depends on whether a long int can represent all values of an unsigned int; if so, the unsigned int operand is converted to long int; if not, both are converted to unsigned long int

Otherwise, if one operand is long int, the other is converted to long int

Otherwise, if either operand is unsigned int, the other is converted to unsigned int

Otherwise, both operands have type int

There are two changes here. First, arithmetic on float operands may be done in single precision, rather than double; the first edition specified that all floating arithmetic was double precision. Second, shorter unsigned types, when combined with a larger signed type, do not propagate the unsigned property to the result type; in the first edition, the unsigned always dominated. The new rules are slightly more complicated, but reduce somewhat the surprises that may occur when an unsigned quantity meets signed. Unexpected results may still occur when an unsigned expression is compared to a signed expression of the same size.
`

代码`return TIME + distance / speed`在实际执行时会被转换成`return (float)TIME + (float)distance / speed`来执行。

之所以思考了那么久才发现问题,是因为在写代码时就知道编译器会进行类型提升。而从理论上来讲,编译器在进行数学运算时,总是会向最大值更大的类型进行转换,以保证隐式转换的正确性。所以首先就把这种错误是类型提升造成的可能给排除了。

这种理论本身也是正确的,因为float的最大值为`340282346638528859811704183484516925440`,可是我忽略了一个很重要的事实,就是float即然叫单精度,就说明它本身是有精度的。


来看一下float的构成.


Float example.svg

float由1个符号位,8个指数位和23位尾数位构成,而精度完全是由23位尾数控制的,因此虽然float可以表示很大,但那是通过指数位进行跳跃(乘以2的N次方)来得到了,换句话说,float能表示的数其实是不连续的,而我们现在的time(NULL)值`1526796356(0101 1011 0000 0001 0000 0100 0100b)`已经使用了31位二进制了,它的二进制有效数字位为29位,已经超出了float中的尾数位。因此在int转向float时必然会丢失精度。

弄清楚了问题之后,只需要加个类型强制转换就可以解决问题了:

//#define TIME time(NULL)
#define TIME 1526796356
time_t foo(int distance, float speed)
{
        return TIME + (time_t)(distance / speed);
}

BTW. 在查阅文档时,发现了一个有意思的事,《The C Program Language》第二版 在算术类型转换一节中特意标注,在第一版中,所有的float类型算术运算都是先转换成double再进行,而第二版的描述是float类型算术运算‘有可能’直接进行,而不是转换成double之后再进行。

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时,在对以前返回的元素进行操作实际上是非法的。因为这块内存已经被释放掉了,而相关数据也已经被挪到新内存了。

bitfield数据类型的坑

bitfield并不具有可移植性,因此实际使用中,我都是尽量使用bitand来代替。

然而代码中之前就已经使用了bitfield的定义方式,作为后续开发我没有理由去改掉这个数据结构(除非它有问题),结果就无意间踩到了这个坑。

bitfield定义和使用大概如下:

union utest {
int val;
struct stest {
int a:3;
int b:5;
};
};

union utest t;
t.value = 0x07;

bitfield冒号后面的数字标识bitfield的位宽,bitfield前面的类型用于标识取出字段后应该变成一个什么样的类型(标准上说仅能支持int, signed int, unsigned int, 然而gcc还支持char, short等类型)。

问题的关键就在于,如果你定义的是有符号类型,那么编译器会将取出的bitfield按照有符号类型进行类型提升

当程序读取变量stest::a时,他会读取utest::val的byte0的低3bit。由于stest::a的类型为int(有符号型类型), 则他将utest::byte0::bit2作为符号位进行整型提升。

也就是说如果utest::byte0::bit2~0的值为110b, 那么你读stest::a时,编译器会将bit2作为符号位来将110b整型提升为0xfffffffe, 即(int)-2;

在实际使用中我使用stest::b作为了一个数组的索引,当stest::b大于0x10时,数组访问直接越界了。

btw, 一般使用bitfield特性时应该很少去依赖于其符号扩展功能(即将其定义为有符号型类型), 因此在将bitfield定义为int而不是unsigned int时一定要再三考虑。

当class遇上union

今天同事又踩到一个以前设计时留下的坑,这次是关于union和class中的。 虽然这种设计我并不认同, 但是至少我觉得设计者对于c++的成员内存布局相当了解。

由于面向对象的存在, 在代码中常常有这样一种用于存储属性的类,类A,类B, 类C,类B继承自类A,类C继承自类B。 而类A, 类B, 类C等这些类的实例都是从socket层传过来的。

作者在设计时为了代码的复用性, 采用了如下设计:

union object {
class A a;
class B b;
class C c;
};

//read_objectX_from_socket函数为伪码, 其实现为逐个读出某个类的成员, 至于这个函数为什么会是这样实现, 这是socket层上的另一个设计问题了, 暂且不谈

void readA(union object &o)
{
read_objectA_from_socket(o.a);
}

void readB(union object &o)
{
readA(o);
read_objectB_from_socket(o.b);
}

void readC(union object &o)
{
readA(o);
readB(o);
read_objectC_from_socket(o.c);
}

从上面看出作者对于C++中的成员变量的内存布局相当有信心, 才会想到使用union的方式来复用代码。

假设这三个类的成员定义如下:class A {int a;}, class B : public A {int b;}, class C : public B {int c;}.

那么此union中的内存布局其实就是A::a, B::b, C::c。
object::a所占的空间就是A::a在union中所占的内存
object::b所占的空间就是A::a和B::b在union中所占的内存
object::c所占的空间就是A::a和B::b和B::c在union中所占的内存

设计者巧妙的利用了union的重叠特性和class的继承特性来完成了代码复用。

大约在上学的时候我也喜欢去hack内存布局(当然没有这种用法这么巧妙), 后来我便渐渐不大喜欢这种做法了.

因为这种代码虽然写起来有种炫技的自豪感, 但事实上一旦出了bug是极难发现的, 人类在汇编语言基础之上又发明了高级语言, 我想也正是因为他们觉得人们需要更多的规则来帮人们减少出错的可能性, 所以我后来便一直主张写出更多可以让编译器检查出错误的代码。


同事踩的坑也正验证了hack内存布局易错不易查的事实。 由于某种偷懒原因, 他实现了class D : public B {int d;}, class F : public C, public D { int f;}。而readF的实现代码如下:

void readF(union object &o)
{
readA(o);
readB(o);
readC(o);
readD(o);

read_objectF_from_socket(o.c);
}

在实现readD时代码看起来依然正常运行, 但是在实现readF时, 明明看到有数据读入,但是类F中继承自C和D的成员总是莫名其妙乱掉(当然这不是我发现的, 这个bug只是我事后知道的罢了)。

此时重新看一下union, 那么C::c和D::d占用的是同一片内存, 那么其实在readF中调用readC和readD时, 覆盖的总是同一块内存。

再看class F的内存布局应该是A::a, b::b, C::c, D::d, f, 也就是说整个readF执行下来, 其实F::D::d这个变量从来就没被操作过, 也就不可能赋值, 由于栈中的随机数, 所以F::D::d这个变量也就变得随机了, 由于object::C::c所占的内存总是会被readC和readD同时操作。因此看上去数据也不那么的有迹可寻。


当第一眼看到这种设计时, 虽然觉得不妥, 但是我并没有找到一种可以不通过hack内存来达到最大代码复用的方式。

在下班回来的路上, 我总觉得应该可以不通过hack内存来达到同样的目的, 终于在快到住的地方时被我想到的了。 其实很简单, 之所以想不到一方面是我不常用C++, 另一方面大概是之前看了这段代码, 一时间先入为主, 思维没有缓过劲来。

其实只需要按照C++最常规的dynamic_cast就可以完成代码的最大复用。

代码接口大概实现如下:


void readA(class A *a)
{
//read a members
}

....

void readD(class F *a)
{
readA(a);
readB(a);
//read D members
}

由于是dynamic_cast, 因此编译器可以帮我们自动去计算每个成员的偏移量, 避免了手动hack可能出现的各种错误。

btw,使用dynamic_cast的方式仅仅能依靠编译器发现这种hack内存时容易出现的bug。 在碰到类似class F这种使用多重继承机制的类时,编译器仅仅会报语法错误,并不能对其父类的readX函数进行复用。

又是权限问题

上周五发布了Beta版之后, 老板觉得这次加的功能挺多就试用了一下, 结果瞬间就崩了, 上去一顿猛批啊。 请他们试用了很久才发现又是因为权限问题。

在%ProgramFiles%下普通用户只有读取和执行的权限, 由于历史原因, 我们Client有一部分DLL是从其他地方copy到client.exe的当前目录下来动态加载的, 而这一版本恰好将管理员权限去掉了(因为Win7及以上版本在管理员权限不能访问网络共享路径), 两个巧合就碰撞到一起导致了这个bug的产生, 当然其实有些代码不是很规范, 不然应该只是加载某个DLL失败而已。

将所有需要动态释放的文件放到了一个与用户相关的目录, 使用DLL的绝对路径去LoadLibrary, 一开始没有任何问题, 直到完全卸载之后安装时就开始发现有些DLL加载不成功, 明明路径存在, LoadLibrary就是会fail。 在MSDN上找到这篇文章之后才找到问题所在, 使用绝对路径去LoadLibrary时, 被使用绝对路径去LoadLibrary的DLL如果静态依赖于另一个DLL, 那么这个被依赖的DLL就会被按照一定的目录顺序搜索, 不幸的是我们释放的DLL所在的路径恰好不在Windows的搜索中径范围之内。 使用MSDN推荐LoadLibrary(path, NULL, LOAD_WITH_ALTERED_SEARCH_PATH)将使用绝对路径加载的DLL所在的目录加入搜索范围问题即可解决。

又一次踩到权限的坑, 以后写代码一定要注意如非有必要尽量不使用root权限, 这样就不会碰到这种中途取消管理员权限之后产生的各种权限及附带引发的其他问题。