引用计数和对象的有效性

之前搞东方弹幕游戏OGG版本的时候,用了这么一套机制:实现自定义的CreateFileA、ReadFile、SetFilePointer、CloseHandle这些函数,然后替换掉程序对这些API的调用。如果CreateFileA打开的文件是thbgm.dat,那么就创建一个读取OGG文件的对象,然后把对象指针加入到一个列表中,并返回给程序;如果调用ReadFile的时候送入的HANDLE是在列表中的某个对象,就执行对象上的Read操作;SetFilePointer同理;如果调用CloseHandle的时候送入的是列表里的某个对象,就把对象从列表中删除,然后delete掉它。

列表我用的是STL里的set对象(虽然unordered_set会比较快?不过那个时候好像还不知道这东西)对于单线程来说,这没有问题,但是多线程的话就会破坏STL容器。所以在访问容器的时候,加了临界区进行保护。这样看起来是没有问题了,但是还是会遇到游戏崩溃的事情……

OGG文件读取用的对象(其实就是libvorbisfile里面的那啥啥)大概也不是线程安全吧,于是我就在读取数据那里也加了临界区保护。这样数据读取是没问题了,可是,还是会崩溃!

崩溃现场不是想捕获就能捕获到的,然后我编译的时候也没有加入debug信息,为了减小文件体积啥的(……)一直搞不清楚为什么会这样。为了重现这个问题,我自己写了个小程序,开4个线程同时不断调用我那个读取数据的函数,每次随机SetFilePointer到某个地方,进行测试。但是情况却是跑了半个钟头,它也没出问题。要崩的话我这样乱来,早就崩了才对啊。

然后有一次,游戏又崩了。我突然想起程序崩溃的时候Windows会在事件查看器里面留下记录,于是赶紧去记下了崩溃地址。之后把源代码用原原本本的参数编译一次,输出map文件。地址一对照,在libvorbis里面 囧

这libvorbis都出了这么久了有bug人家应该早修了才对,毕竟被用得这么广。好奇怪不知道为什么。然后有一次不知道怎么想的,就突然想看一下游戏对于这些函数调用是什么样的吧,就突然发现它在切换音乐的时候(比如游戏中暂停)会先CloseHandle。

考虑了许久,我在做最早版本这种游戏背景音乐OGG化的时候,是创建一个全局的读取对象,忽略在它上面的CloseHandle和CreateFileA操作,总之就是进程生命周期内都存在。后来进行改进,改成CreateFileA的时候创建一个实例,CloseHandle的时候删除实例了。那会不会是数据还正在读取的时候,就因为CloseHandle被调用而导致对象被暴力地删掉了,然后解码一半的代码就死了:用到的一些保存解码状态啊控制信息的结构体被释放了。因为我在释放对象的时候没有考虑它还在被使用的情况。想想看,很有可能,于是我就在析构函数里加了和解码的时候一样用的临界区。但是看着这样的代码,还是很不放心。因为仔细分析的话,还是有很多执行顺序问题会导致它崩溃。最烦的一个就在于临界区的处理那边:因为最后要DeleteCriticalSection,而这个时候你不知道有没有线程在临界区上等待或者正准备进入临界区。虽然加了几个flag来处理,但是……

image

假设有两个线程,然后

线程1:  1   2 3 4
线程2:5   6

然后线程2引发了崩溃。

想了半天也不知道到底应该怎么解决这个临界区的删除问题,然后想了某歪招

image

解释是:我先设定一个flag,我设完flag以后你们就都进不来,然后我设flag是已经进了临界区了,所以也没有其他正在临界区内的线程。那那些已经进来的怎么办?行,我先出临界区,然后等你1毫秒,这段时间里你已经过了外面那句if的赶快给我出来,已经在临界区上等的赶快进了临界区然后出来,之后我就可以删掉临界区了,因为没人能再进来了。(现在想想看这个时候写的代码,如果出现如下顺序,线程1就永远死掉了……)

线程1:1   2   3    4 5 6
线程2:  8   9   10

实在是不知道该怎么弄才好了。

然后我去问Advance了。得到的答复是,这种情况可以用引用计数来解决,这样在CloseHandle里面就不需要delete对象了,而是减少一次引用。这样如果有线程正在用它读取数据,就会变成读取数据的线程释放引用的时候将它析构,也就确定了析构函数一定不会和其他函数出现并发的调用。

一听,这不错啊,我把对象返回给程序的时候,引用是1,然后所有操作都要先增加一次引用再操作,完成以后减少一次引用。最后释放的时候减少一次引用,在减少引用操作里面delete this。于是有了以下代码

image

但是我越看越不对劲,总觉得它会崩。因为如果这样的顺序执行

线程1:29    31    32
线程2:   37    38

虽然29在37之前,但是因为线程切换的问题,很可能最终还是Release先被执行了,然后delete this……

由于考虑这个问题,想起COM组件里面实现IUnknown接口的时候似乎都这么写,那岂不是到处做的COM组件都有问题?为什么会这样,网上搜了很多,很多都是这么做,一时不得其解。比如这个 http://stackoverflow.com/questions/18076741/com-c-is-interlock-apis-enough-for-thread-saftey-in-addref-and-release

花了很多时间,终于把这事情想通了。最初创建COM组件的时候,引用计数是1,此时如果直接调用Release,那么就删掉了。但是对于COM对象来说,要把它传给其他线程啊对象啊啥的地方使用的时候,总是要调用AddRef的,也就是说,如果它被传给了另一个线程使用,那么Ref会被加上1,到头来如果Release里面调用delete this,就代表_count一定是1,于是就可以说只有一个线程持有这个对象,那么也就不可能和其他成员函数(方法)同时被调用。

那么我到底遇上了什么情况呢?就是我传出去的对象,人家就算传给其他线程使用,也不会给你AddRef的。于是,囧了,AddRef的时候很可能这个对象已经无效了。

再想想看,自己是要实现CreateFileA、ReadFile和CloseHandle,当然行为和他们越像越好,在ReadFile返回之前调用CloseHandle这类的行为,系统是不会蓝屏的。于是也要解决这个问题……

之后我使用了Advance介绍的方法,维护一个全局的有效对象的列表,对象构造的时候把对象指针加入到列表中,析构的时候从列表移除,然后AddRef的时候检查对象是否在列表中,如果不在则通过某种方式通知AddRef的调用者(我的方法是返回0,因为正常情况AddRef是不可能返回0的)。

这样子,就弄出了如此一个基类

//错误代码请勿直接参考
class ReferenceCounter
{
    volatile unsigned int _count;
public:
    ReferenceCounter();
    virtual ~ReferenceCounter();
    int AddRef();
    int Release();
private:
    //no impl
    ReferenceCounter(const ReferenceCounter&);
    //no impl
    ReferenceCounter operator = (const ReferenceCounter&);
};

然后就有了如下代码

//错误代码请勿直接参考
static std::set<ReferenceCounter*> s_setAvailObjs;
CRITICAL_SECTION s_csAvailObjs;

static class InitCSCaller {
public:
    InitCSCaller() {
        InitializeCriticalSection(&s_csAvailObjs);
    }
} s_initcs;

ReferenceCounter::ReferenceCounter() : _count(1)
{
    EnterCriticalSection(&s_csAvailObjs);
    s_setAvailObjs.insert(this);
    LeaveCriticalSection(&s_csAvailObjs);
}

ReferenceCounter::~ReferenceCounter()
{
    EnterCriticalSection(&s_csAvailObjs);
    s_setAvailObjs.erase(this);
    LeaveCriticalSection(&s_csAvailObjs);
}

int ReferenceCounter::AddRef()
{
    EnterCriticalSection(&s_csAvailObjs);
    if (s_setAvailObjs.find(this) == s_setAvailObjs.end()) {
        LeaveCriticalSection(&s_csAvailObjs);
        return 0;
    } else {
        unsigned int r = InterlockedIncrement(&_count);
        LeaveCriticalSection(&s_csAvailObjs);
        return r;
    }
}

int ReferenceCounter::Release()
{
    EnterCriticalSection(&s_csAvailObjs);
    unsigned int r = InterlockedDecrement(&_count);
    if (r == 0) delete this;
    LeaveCriticalSection(&s_csAvailObjs);
    return r;
}

之后,我的那个读取OGG文件的对象就从这个基类派生。自己实现的ReadFile等函数中,首先就把HANDLE hFile当作我这个对象看待,在上面调用AddRef。如果返回0,就再送到真正的系统API中。如果不是返回0,就调用对象上的方法。看起来很完美,判断对象的时候还有临界区,然后还用了引用计数。没问题了是吗?

好,写了一段代码来测试,开4个线程,每个线程利用随机数,在文件中seek、读取数据、关闭文件、打开文件。然后开起来跑,两下半程序就崩溃了。崩溃的地方在vorbisfile.c(libvorbisfile 1.3.3)里面,1199行,那个vf->pcmlengths是0。

很神奇,引用计数也没问题,为什么会崩。寻找这个成员被初始化的地方,然后下断点,明明是在打开文件那时候就已经初始化了不应该是0。又在这上面想了半天,就是想不通它是怎么变成0的。甚至还在为这个成员申请内存那边下条件断点,看看是不是0,结果没有断下来。

想了半死,突然间脑子一抽,尼玛的如果一个内存块释放以后马上再申请,很有可能返回的是同一个地址对吧?然后我对象的类型继承自ReferenceCounter,而ReferenceCounter在构造函数里把自己标记为有效对象。乍看上去没问题,但是仔细一想,C++的构造函数调用顺序是先父类再子类,也就是说存在这样一种状态,ReferenceCounter的构造函数已经调用,但是子类的构造函数还没调用,这种状态下,对象已经被加入到一个全局的“有效列表”中,但是由于子类的构造函数还没有调用,事实上这个对象是无效的。如果这个时候有其他线程把这个指针送进来,在上面调用方法,就相当于在一个无效的对象上面调用方法。这样说可能太抽象,弄个简单的示意,就是

线程1:[delete pObj]-[调用子类析构函数]----[调用父类析构函数]------
线程2:----------------------------[获取变量pObj]---------------
线程3:--------------------------------------------------------

线程1:[释放内存]-[pObj=NULL]-----------------------------------
线程2:-----------------------------------------[pObj->read]--X
线程3:--------------[pObj2=new]-[调用父类构造函数]--------------

上面描述中,read是子类的方法,父类指的是ReferenceCounter,它在构造的时候将自己添加到有效对象列表,析构的时候将自己从列表移除。

看似严密的对象的合法性检查出现了漏洞:线程1和线程2同时拥有这个对象的指针,然后线程1将其释放,而后线程3创建了一个新的对象,这个新的对象正好地址和旧的对象相同,就在新的对象构造一半(已经完成ReferenceCounter的构造,被加入到有效对象列表,但是却还没完成子类部分的构造)的时候,线程切换了,线程2开始在上面执行read操作了。因为对象已经在有效对象列表,所以read操作在检查对象有效性之后,就在上面开始读了,程序就崩溃了。

对程序进行修改,在ReferenceCounter里加一个Active成员函数,代替原来构造函数的功能;构造函数只保留初始化引用计数的功能。对象在创建完之后,由创建线程调用Active使之有效化。再运行测试代码,程序没有崩溃了。

为了验证对其崩溃原因的猜测,我又把代码恢复成原来的样子,用测试代码去调用,崩溃了,查看崩溃现场,果然有一个线程正在执行它的构造函数,而且this指针和读数据读崩溃的线程的this指针是一样的。

那么析构函数那边,为什么不会有这种事呢?既然析构函数也是先子类再父类,而把对象从有效列表中删除是在父类的析构函数里面做的,如果子类的析构函数已经执行,而父类的还没执行,那就会处在一种对象无效但是却在有效列表中的情况。实际上这个和最初考虑释放临界区的时候一样,因为对象如果要被删除,那么引用计数在减去1之后已经是0了;Release和AddRef都受全局临界区保护,不可能同时被执行;而AddRef只有在对象存在于有效列表中的时候才会正常返回,否则返回0。如果存在Release函数执行过程中该对象上的某个方法正在被调用的情况,那么该对象一定在其他地方有引用,也就是说引用计数递减之后不会为0,那么delete this也就不会被执行。析构函数在执行过程中,AddRef是无法同时执行的,所以也不用担心析构函数执行一半其他什么线程AddRef一下然后开始调用上面的方法了。

不过因为临界区中的代码是执行得越快越好,所以我想也可以把delete this从Release里的临界区中移出来,而改为在Release的临界区中只将对象移出有效列表,出了临界区再delete this;delete this的时候就不做“将对象移出有效列表”的事情了。修改过的代码用之前那个4线程随机动作的东西测试,跑了一两分钟,没问题。

发表评论