[U3D]StreamedBinaryRead::TransferSTLStyleArray崩溃分析

当崩溃发生在U3D引擎时,通常有三个选择:

  • 调整使用姿势,远远的绕开
  • 无视崩溃的存在,继续开心的写代码
  • 重现问题,发送Bug Report

对于一些几乎无法重现的问题,即使发送 Bug Report 也无济于事。

崩溃收集总能看到一些奇奇怪怪的堆栈,例如:

libunity.004e6b14(Native Method)    dynamic_array<GameObject::ComponentPair, 0u>::resize_initialized(unsigned int, ResizePolicy)  ??:? 
libunity.004e6dc4(Native Method)    void StreamedBinaryRead::TransferSTLStyleArray<dynamic_array<GameObject::ComponentPair, 0u> >(dynamic_array<GameObject::ComponentPair, 0u>&, TransferMetaFlags)  ??:?  
libunity.004e3fc0(Native Method)    void GameObject::TransferComponents<StreamedBinaryRead>(StreamedBinaryRead&)  ??:? 
libunity.004e3e64(Native Method)    void GameObject::Transfer<StreamedBinaryRead>(StreamedBinaryRead&)  ??:? 
libunity.00360738(Native Method)    SerializedFile::ReadObject(long long, ObjectCreationMode, bool, TypeTree const**, bool*, Object&)  ??:? 
libunity.00361744(Native Method)    PersistentManager::ReadAndActivateObjectThreaded(int, SerializedObjectIdentifier const&, SerializedFile*, bool, bool, PersistentManager::LockFlags)  ??:? 
libunity.003618f4(Native Method)    PersistentManager::LoadObjectsThreaded(int const*, int, LoadProgress&, bool, PersistentManager::LockFlags)  ??:? 
libunity.00416f6c(Native Method)    LoadOperation::Perform()  ??:? 
libunity.00418f90(Native Method)    PreloadManager::ProcessSingleOperation()  ??:? 
libunity.00418de4(Native Method)    PreloadManager::Run()  ??:? 
libunity.00418d70(Native Method)    PreloadManager::Run(void*)  ??:? 
libunity.003dcd98(Native Method)    Thread::RunThreadWrapper(void*)  ??:?

或者连调用栈都不完整,只剩一句:

libc.memset(memset:240)

本文记录针对这个常见,又一直没被Unity解决的Bug分析过程(保证干货满满)。

想知道解决方案,请直接跳跃至文章末尾。


看到第一种调用栈,如果你遇到了会怎么思考呢?是不是内存设备内存不足导致了崩溃?

如果进一步查看崩溃时,设备的内存情况——发现内存非常宽裕呢?

用IDA分析崩溃所在函数,就会发现崩溃时正在向非法的内存地址写数据

这个函数首先检查容量是否足够,如果不够就重新分配内存,并把增长部分数据清零。崩溃发生在数据清零,原因是内存分配失败,那分配失败的原因又是什么呢?这就需要检查下分配的内存大小了,通过分析 void StreamedBinaryRead::TransferSTLStyleArray() 函数,了解到,首先读取数组长度,然后根据读到的长度分配内存,随后把数据读入刚分配的内存。(此处的dynamic_array类似std::vector)

StreamedBinaryRead有一个成员变量,类型为CachedReader,CachedReader会一次读取7KB作为缓冲,避免频繁的文件IO,当StreamedBinaryRead请求的数据在缓冲内时直接从缓冲内存返回数据,如果不在,则会通过CachedReader::UpdateReadCache更新缓冲数据,每7KB一个块。

想知道当时分配了多少内存该怎么办呢,跟踪寄存器的使用情况,发现存储内存分配大小的存器在函数调用期间幸存了下来,即使没有dump,凭借crash log中寄存器的情况就可以判断出当时要分配的内存大小,综合多份日志,发现需要分配的尺寸千奇百怪,有负数也有分成大的正数,大到不实际的尺寸。到这一步,就可以知道:读取了错误的数据当作长度,内存分配失败,导致崩溃。而调用栈都没有或者错误的情况,因为用了错误的长度memset或memcpy栈内存导致函数返回地址被覆盖。

接下来要考虑的事情就变成了为什么读取到了错误的数据。没有源码,怎么办呢?先从现象着手收集有用的信息吧。首先想到的是那个奇怪的长度到底从哪读取的?从几百个样本中挑选了几个特别奇怪的数字,在AssetBundle中搜索,其中有的数字在整个项目AB包中只出现了一次,有的出现非常多次,有的甚至没有出现过,这就很郁闷了,只能从出现一次的情况入手,进一步了解情况。

对于只出现一次的魔鬼数字做了调查后,其中有一个样本来自于贴图的StreamData,也就是说,把颜色值当作了长度,多么可怕的事情!这部分数据位于.resS段,Unity的官方博客是这么说的:

Optimizing loading performance: Understanding the Async Upload Pipeline – Unity Blogblogs.unity3d.com图标

对于不可读写的Texture,不可读写并没启用压缩的网格,Unity会把这部分数据放到.resS"文件"中,官方这么称呼,但这么称呼很容易和实体的文件混淆,它其实就是一个数据段。而AssetBundle就是上图中的数据压缩、加上文件头的结果

通过调试追踪分析,了解到,文件的反序列化的任务由PreloadManager负责,它有一个线程负责从SerializedFile反序列化数据到实例类中,反序列化完成后调用对象的AwakeFromLoadThreaded()。而.resS中的数据并不是由PreloadManager负责加载的,而是AsyncUploadManager。网格和贴图AwakeFromLoad之后会向AsyncUploadManager提交加载任务,AsyncUploadManager负责异步读取数据向GPU上传数据。

打AssetBundle时会用到这个API :BuildPipeline.BuildAssetBundles(),传给它的打包参数中有一个枚举是 ChunkBasedCompression 文档中是这么说明的:

Use chunk-based LZ4 compression when creating the AssetBundle.

在ChunkBasedCompression实现中,会把上图中的数据 (SerializedFile + .resS)每0x20000B(128KB)分为一个块,各个块分别使用LZ4压缩,每个块的元信息会被存储到AssetBundle的文件头中。

本文只讨论设置了ChunkBasedCompression的AssetBundle,其他模式不存在本文讨论的Bug

正在一筹莫展的时候,拿到了一份iOS设备崩溃的core dump (这可帮了大忙)。

通过core dump的粗略检查,印证了先前的猜测。有dump还有数据,真是太好了,可以深入分析事故现场了。

首先需要调查的是错误数据的全貌,它来自哪里?CachedReader。它是StreamedBinaryRead类的一个成员。

CachedReader是缓冲读取类,它记录了当前7KB buffer的内存起始、结束地址、当前读到哪里,当前buffer对应到第几个7KB块,块的大小(7K) 等等。

Dump数据中,当前CacheReader的buffer位于内存0x0000000138753c10 ~ 0x0000000138755810, 当前读取到 0x00000001387551e0,附近的内存如下:

拿这段内存到资源文件中搜索,发现没有完全一致的,只有部分匹配。在这里纠结了不少时间,因为这里忘记了一件非常重要的事情,AssetBundle文件是压缩后的数据,而内存中用来反序列化的数据是解压后的数据。这就是起初没能搜索到完全一致内容的原因。改写AssetStudio解压所有AB包后再搜索,发现有8处完全命中,得想办法再排除掉一些。

到这一步,最先想到的是Unity读错了文件——认为可能是加载、卸载AssetBundle引起的问题,后来证明这个想法是错误的。

突然想到可以通过 Path ID 来确定当前正在反序列化哪个Object,Path ID 有64位,在8个文件中冲突的概率非常低。

什么是Path ID? 官方的介绍文档在此处:Assets, Objects and serialization - Unity
Unity这里的命名一直都比较混乱,FileID 又叫做 File GUID,而 Path ID 又叫做 Local ID 或 Local File Identifier。File ID在工程中用于关联到另一个资源文件,而在AssetBundle中用于关联另一个AssetBundle,而Local ID 或 Path ID 则对应到资源文件或AssetBundle中的某个具体的Object。

如何才能在内存中找到这个 Path ID 呢?StreamedBinaryReader只负责数据读取,应该不太可能记录这个对它没用的数据,因此决定往栈上回溯,反序列化Object前一定知道,一定有地方记录了。于是顺着调用栈往回找线索,注意到CachedReader初始化时需要对象数据在文件中的偏移和大小,再看看数据是怎么来的呢?来自于 sorted_vector::find() 调用,根据超级长的模板信息,可以知道其中存储了std::pair<long, SerializedFile::ObjectInfo>,find的参数一定就是 Path ID了,这个64位被记录在了栈上,那就从堆栈上找到它吧,下面是当时堆栈时的笔记。

D4DBFCC8045EF6B0,再用AssetStudio扫了一遍8个嫌疑犯,目标只剩一个了。到这一步,就知道当前正在反序列化哪个文件的哪个资源了。

接着把错误的7KB数据用lldb memory read写到磁盘上后,再进一步定位错误数据在文件中的位置。

当时并没有足够的证据表明错误的数据一定来自正在读取的文件,因为8个文件都包含错误数据,但由于同一个文件既有正确数据又有读错数据,相比读错文件而言,同一个文件内读串的情况直觉上概率更大些,

同时CachedReader中记录了当前Cache对应到第几个7KB块,通过它就可以知道——本来应该读取什么数据。

在事故现场,原本需要读取 0x96800 开始的7KB,却读到了 0x2f6800开始的 7KB数据。光看地址就能发现不对劲的地方,0x2f6800居然不是7K对齐的地址!!!

0x2f6800来的可不像说的那么简单,起初得到的偏移并不是这个值,甚至不是4B对齐的数,这就更让人郁闷了。AssetStudio会把SerializedFile和.resS分成两个流,因此保存的时候也是保存了两个文件,SerializedFile 0x0 ~ 0x192b97,.resS的范围 0x192b97 ~ 0x2f8700,SerializedFile的末尾是没有padding的,错误的那段数据因为在.resS中,因此偏的很厉害,得到的偏移完全没有继续研究的价值,直到迷迷糊糊睡了一觉后,得到一些灵感:正在请求的块位于SerializedFile的末尾,而Unity并没有读到SerializedFile末尾就停止了,请求的大小依然是7K,如果把SerializedFile和.resS当作两个流处理,会造成读取越界,Unity没有这么处理说明它把两个流当作一个整体。这一点引起了我的注意。因此后来尝试合并另个流数据后得到了0x2f6800看上去科学的多的数字。
此外因为先前图省事,磁盘上有一份用AssetBundleExtractor解出来的数据,也对研究造成了很大干扰,ABE解压出来的数据两个流虽然是合并的,但是却加上了额外文件头,这也导致了两个地址的混乱,正是在一筹莫展时,几个数据文件间来回切换时发现了这个问题。

读取到的错误数据为何不是7K对齐的这个问题魔鬼一样挥之不去。

在纸上比比划划,画了画文件布局,标记了几个地址,相对偏移,尝试找到其中的内在联系。对着草稿半小时后,突然想到还有LZ4块没有被考虑进来。当引入LZ4块后0x2f6800突然的变得有意义了

0x96800,本应该读取的数据在文件内的偏移,它对应的LZ4块的起始地址是0x80000,

两者之间的相对偏移是0x16800。

0x2f6800,读到的错误数据在文件内的偏移,它对应的LZ4块的起始地址是0x2E0000,

两者之间的相对偏移是0x16800!

这个发现指引了进一步的研究方向,多么有意义的数据:

通过这一点基本就可以排除掉读到其他文件的可能了,同时可以大胆的假设CachedReader在获取下一个7K的时候,读取了其它的LZ4块上的数据。

当前请求的数据从0x96800开始到0x98400,这段数据理应在已经解压的LZ4块中。

当时并不知道LZ4解压数据是如何处理的,有可能用完就释放,也有可能把解压的数据都保留下来,也有可能保留几块,根据经验最后一种情况可能性最大,在后来研究中发现,每次请求首先找相匹配的缓冲,有的话直接拷贝,没有的话找一个不在读也不在写的最早使用块,如果找不到这样的缓冲块就新分配一个,这里没有释放策略,释放是在虚拟文件句柄中处理的。

知道以上这些信息后,对问题的成因做了下列猜想:

CachedReader在获取下个块的时候,认为需要的数据已经在LZ4缓冲块中,因此可以直接拷贝需要的数据,但因为另一个线程认为这个LZ4块可写,就把数据写入了这个块,后面实际拷贝的时候就拷错数据了。

要确认这一点还需要再往下挖一层 —— 研究CachedReader的数据来源,发现数据来自 FileCacherRead (我真的不太喜欢这个类名,可Untiy就是这么取的啊) 。FileCacherRead的代码量不多,因此逆向工作很快就完成了。

FileCacherRead主要功能就是接收来自CachedReader的请求,然后根据请求填写AsyncReadCommand,Command的接收者是AsyncReadManagerThreaded。

struct FileCacherRead
{
    void* vftable    // 虚函数表
    // ...省略
    AsyncReadCommand m_ReadCommands[2]
    AsyncReadCommand m_DirectReadCommand;
    // ...省略
};

AsyncReadCommand 是什么呢? 看看这个就知道了:

Unity - Scripting API: ReadCommanddocs.unity3d.com图标

m_ReadCommands 和 m_DirectReadCommand 有何区别呢?

m_ReadCommands 用于投放到 AsyncReadManagerThreaded的任务队列。而 m_DirectReadCommand 用于调用AsyncReadManagerThreaded的函数直接读取数据。虽然前者采用的是任务队列的模式,但是通常后面都会跟着一个 FileCacherRead::SyncReadCommandBlock(),同步等待任务的完成。只有流式的数据才有真正的异步等待。

之所以提起这个,因为两点原因,在问题诊断中,他们的状态对于反推事故原因很有帮助。此外这里引入了AsyncReadManagerThreaded的两种工作模式:异步任务队列和同步函数调用。

  • AsyncReadManagerThreaded::SyncRequest() 会立即打开文件,读取需要的数据,然后关闭文件。
  • AsyncReadManagerThreaded::Request() 会把command加入到消息队列,然后在它的线程里处理请求,在线程中处理command的函数是 AsyncReadManagerThreaded::PumpRequests(),同样也是打开文件,读取需要的数据,再关闭文件,调用callback设置状态完成。
这里提到了打开文件,关闭文件,其实是有缓冲池的,关闭操作会把文件加入到缓冲池中,并不会立刻关闭。
而且这里的文件是虚拟文件,Unity中通过虚拟文件系统,隐藏了磁盘文件和内存文件的差异,AssetBundle的数据通过ArchiveFileSystem挂载到虚拟文件系统,读取实现由ArchiveStorageRead负责。

AsyncReadManagerThreaded的这两种工作方式,使用了不同的mutex,因此文件打开、读取、关闭操作时可以在两个线程同时发生,虽然采用不同的mutex,但这些操作内部都有很多小粒度的锁保护,所以这里不是问题的根源,但却为bug创造了条件。

进行到这里,对于先前的猜测有了更大的信心,毕竟存在两个线程访问数据的可能,如果这里有一个大锁,调查就可以在这一步终止了。

在继续深挖下去之前,先说下FileCacherRead 当时的状态,FileCacherRead.m_ReadCommands[0] 的数据时空的,没有被使用过。

而 FileCacherRead.m_DirectReadCommand 中请求的偏移是0x040c00 大小为 0x055c00,正好读取到0x96800,换句话说就是从 0x040c00的地方开始读了 12 个 7KB块,而这些数据都是正确的,通过dump内存,比对文件可以确认这一点。

FileCacherRead.m_ReadCommands[1] 中有数据,出问题的请求正是由它发出的。它请求 0x096800 开始大小为7K的数据。请求的参数本身没有问题,进一步说明问题埋在更深的地方。

挂载到虚拟文件系统的AssetBundle文件名是什么呢?请看下图:
是不是有点熟悉?Profiler里经常能见到,对吧?

进行到这里,已经有充分的理想相信问题出在文件读取,而不是打开和关闭操作,因此顺着File::Read()跟进去看看内部到底是如何实现的。

虚拟文件系统大量的虚函数调用还有多重继承和一堆乱七八糟的handler,靠静态分析已经很困难了,因此写个小工程挂上调试器看一下AssetBundle到底由哪个子系统实现的。结果是ArchiveFileSystem 和 ArchiveStorageRead,前者是子系统的管理器,后者对应到某个特定的文件实例。因为目标很明确,没有深究ArchiveFileSystem,粗略过了一遍函数名,最终目标定位到 ArchiveStorageRead。此处又是一个通宵。

通过分析 ArchiveStorageRead 可以知道什么呢?

读取操作的入口是ArchiveStorageReader::Read(),根据请求的读取是否完整覆盖整个压缩块判断是进一步调用ArchiveStorageReader::ReadBlock()还是ArchiveStorageReader::ReadCompleteBlock()。

ArchiveStorageReader::ReadCompleteBlock() 中没有复杂的内容,用伪代码表示的话大致如下:

bool ArchiveStorageReader::ReadCompleteBlock(block, ...)
{
    if (!IsCompressed(block))   // 判断是否压缩
        return ReadFromStorage(...) // 直接读取物理文件

	// 栈上构造 CacheBlock, 
	// 不要和CachedReader的Cache混淆,这里指代的是LZ4解压块的Cache)
    CachedBlock cacheBlock(...);    
    ReinitCachedBlock(&cacheBlock);

    if (IsStreamed(block))      // 判断是否流式
        FillStreamCachedBlock(&cacheBlock, size);
    else
        FillChunkCachedBlock(&cacheBlock);
        
    // ... 此处省略流式的解压的相关处理
}

这个函数中CachedBlock是栈上分配到的,因此不存在资源竞争的情况,因此可以把注意力放到 ArchiveStorageReader::Read() 函数,下面是这个函数实现的调用逻辑。

bool ArchiveStorageReader::ReadBlock(block, data,...)
{
    if (!IsCompressed(block))   // 判断是否压缩
        return ReadFromStorage(...) // 直接读取物理文件
    
    CachedBlock* cacheBlock;
    
    // 如果有缓冲命中,则直接获取现有的缓冲
    cacheBlock = AcquireCachedBlock(block, ...);
    
    // 如果缓冲没有命中,如果老的缓冲不在使用,返回一个最早使用的
    // 如果没有老缓冲可以复用,就创建新的CacheBlock
    if (cacheBlock == NULL)
        cacheBlock = AcquireAndPrefillUnusedBlock(block, ...);
        
    if (cacheBlock != NULL)
    {
        // 省略此处伪代码
        // 从cacheBlock拷贝数据到data缓冲区
        
        ReleaseCachedBlock(cachedBlock);
        return true;
    }
    
    // 省略无关逻辑
    return false;
}

因为先前已经根据现象,对Bug的成因做出了合理的假设,因此排查方向是很明确的,但并没有开始地毯式搜索逻辑问题,而是决定先看一下core dump中,此时被缓冲着的CachedBlock是什么样的状态。

由于 CacherReader 和 ArchiveStorageReader 之间通过通过文件名解耦合了,CacherReader和它的成员中没有直接存储 ArchiveStorageReader 的引用,因此需要在内存中找到出问题的 ArchiveStorageReader。

通过梳理类关系,和检查AssetBundle加载时Archive到底Mount到哪里了,发先一个找到目标 ArchiveStorageReader 的路径。

  1. 首先要找到 gAssetBundleFileSystem,通过lldb反汇编用到它的函数,再根据arm指令计算出在内存中的位置。
  2. 找到 gAssetBundleFileSystem 后有一个成员变量存储了 ArchiveFileSystem 的指针,根据偏移获取到 ArchiveFileSystem 的指针。
  3. ArchiveFileSystem 中用一个vector存储了所有挂载着的 ArchiveStorageReader (幸好是vector,如果是tree那就比较恶心了)。
  4. 利用lldb的python脚本接口,遍历这个vector,打印每个 reader 的内存地址和虚拟文件路径。
  5. 然后根据虚拟文件路径找到目标 reader。

下面是debug时的笔记:

在问题资源的ArchiveStorageReader上发现存在着两个CacheBlock,dump数据后观察,其中一个CacheBlock存储了问题数据,而另一个CacheBlock存储了另一份.resS中的数据。CacheBlock上有记录一个TimeStamp,根据TimeStamp可以了解请求的顺序:包含问题数据的请求是最后一次。

在此前整理ArchiveStorageReader::ReadBlock()逻辑的时候对于 AcquireCachedBlock() 和 AcquireAndPrefillUnusedBlock() 的功能做了粗略的了解,没有细看实现,里面有大量的原子操作,一眼望去非常混乱。此时可以了解的情况是:

  • AcquireCachedBlock() 中检查如果存在缓冲块和请求的一致,就返回这个缓冲块;
  • AcquireAndPrefillUnusedBlock() 从现有的缓冲块中找一个不在使用的,并且根据TimeStamp优先选用最旧的那个。如果找不到合适的就新分配一块。然后重置缓冲块,从原始文件读取数据,解压,填入新请求的数据。

到这一步,要收集的数据都收集到了,然后开始拼图游戏了。

按时间排序事情的发生经过,

  • 最后处理的请求是读取.resS,它存储了出错数据的源头。
  • 再往前一次请求也是读取.resS,看上去没有直接关系,
  • 再往前,还有两次请求,一次通过DirectReadCommand读到0x96800,另一次通过m_ReadCommands[1]从0x96800开始读7KB。两者的先后关系虽然没有明确的证据证明,但顺序读取是合情合理的,而且根据出错原因的猜想,决定假设DirectReadCommand再m_ReadCommands[1],关于这一点会在下面解释。

对此,我的给的假设是这样的:

  1. SerializedFile::ReadObject()中反序列化数据的时候,需要读取一大块数据,产生了编号为N的请求;
  2. 需要接着读取数据后面的数据,产生了编号为N+1的请求,使用了0号CacheBlock,请求的方式是 AsyncReadManagerThreaded::SyncRequest(),这个请求在PreloadManager的线程上执行;
  3. 但是在获取到0号,还没拷贝数据之前,这个线程停下来了;
  4. 在另外一个线程——AsyncReadManagerThreaded 的异步请求处理线程中,pump了一个request,产生了编号为N+2的情况,使用了1号CacheBlock;
  5. AsyncReadManagerThreaded在处理完N+2后,接着pump下一个Request,编号为N+3,拿到了0号CacheBlock,填充数据;
  6. PreloadManager所在的线程恢复,开始从0号CacheBlock拷贝数据,而0号CacheBlock的数据已经改变;
  7. 错误的数据返回给SerializedFile::ReadObject —— Crash!

这个假设合情合理,但是需要证明。首先提出一个问题:

是否存在 CachedReader 到 AsyncReadManagerThreaded::SyncRequest() 的调用路径?

故障原因的假设需要有两个线程争夺资源,如果PreloadManager的线程无法通过CachedReader同步调用到AsyncReadManagerThreaded,那假设是无法成立的,所有读取都通过消息队列的方式是无法产生线程竞争CacheBlock的。

因此开始搜索AsyncReadManagerThreaded::SyncRequest()的交叉引用,不断往caller追溯,发现这个条件是完全没有问题的。也就是说 PreloadManager 的线程是能够调用到 AsyncReadManagerThreaded::SyncRequest() 并进一步走到 ArchiveStorageReader::ReadBlock() 函数的。

下一个问题,

从File::Read()到ArchiveStorageReader::ReadBlock() 之间是否存阻止ReadBlock()多线程调用的锁?

检查后发现不存在这样的锁。

下一个问题,

N+3 号请求在什么样的条件下有可能获取到0号CacheBlock?

要解决这个问题,需要对 ArchiveStorageReader::AcquireCachedBlock() 和 ArchiveStorageReader::AcquireAndPrefillUnusedBlock() 进行仔细分析。

在研究这个Bug的过程中,只对这两个函数做了伪代码翻译、整理,便于充分理解其中的逻辑。之所以这么做是根据经验要出bug,一定藏在这两个函数里面。文中其他的伪代码都为了讲解在撰文过程中补上的,省略了很多实现细节。
CachedBlock* ArchiveStorageReader::AcquireCachedBlock(ArchiveStorageReader *this, unsigned int requestBlock, unsigned int offset)
{
    m_Mutex.Lock();
    
    if (m_CachedBlocks.size() == 0)
    {
        m_Mutex.Unlock();
        return NULL;
    }
    
    for (int i = 0; i < m_CachedBlocks.size(); i++)
    {
        CacheBlock* cache = m_CachedBlocks[i];
        if (cache->block != requestBlock)
            continue;
    
        if (!IsStreamed(requestBlock))
        {
            // LZ4 会走入这个分支
            m_Mutex.Unlock();
            
            cache->Atomic_AcquireReadAccess();
            
            while (cache->Atomic_HasWriter()
                Sleep(0.01);
            
            m_Mutex.Lock();
            
            CacheBlock* selectedCache = NULL;
            
            if (cache->block != -1)
            {
                cache->timeStamp = m_TimeStamp++;
                selectedCache = cache;
            }
            else
            {
                // 抛开文中的Bug,我认为这个分支完全没有道理的
                cache->Atomic_ReleaseReadAccess();
            }

            m_Mutex.Unlock();
            return selectedCache;
        }
        
        // CacheBlock可以有多个Reader但是最多只能一个Writer
        if (cache->Atomic_AcquireWriteAccess() == false)
            continue;
        
        if (cache->IsOffsetInDataRange(offset))
        {
            cache->Atomic_AcquireReadAccess();            
            cache->timeStamp = m_TimeStamp++;
            m_Mutex.Unlock();
            
            return cache;
        }
        
        cache->Atomic_ReleaseWriteAccess();
    }
    
    m_Mutex.Unlock();
    return NULL;
}

CachedBlock* ArchiveStorageReader::AcquireAndPrefillUnusedBlock(ArchiveStorageReader *this, unsigned int requestBlock, enum CacheResult *cacheResult)
{
    m_Mutex.Lock();
    
    CacheBlock* selectedCacheBlock = NULL;
    DWORD lowestTimeStamp = m_TimeStamp;
    
    if (m_CachedBlocks.size() != 0)
    {
        for (int i = 0; i < m_CachedBlocks.size(); i++)
        {
            CacheBlock* cache = m_CachedBlocks[i];
            
            if (cache->Atomic_AcquireWriteAccess() == false)
                continue;
            
            if (cache->Atomic_HasReaders())
            {
                cache->Atomic_ReleaseWriteAccess();
                continue;
            }
            
            // 使用最旧的可读写 CacheBlock
            if (selectedCacheBlock != NULL &&
                cachePtr->timeStamp >= lowestTimeStamp)
            {
                cache->Atomic_ReleaseWriteAccess();
                continue;
            }
            
            if (selectedCacheBlock)
                selectedCacheBlock->Atomic_ReleaseWriteAccess();
            
            selectedCacheBlock = cache;
            lowestTimeStamp = selectedCacheBlock->timeStamp;
        }
    }
    
    if (selectedCacheBlock == NULL || m_CachedBlocks.size() == 0)
    {
        selectedCacheBlock = new CacheBlock();
        m_CachedBlocks.push_back(selectedCacheBlock);
    }
    
    ReinitCachedBlock(selectedCacheBlock, requestBlock);

    m_Mutex.Unlock();
    
    CacheResult ret;
    
    if (IsStreamed(selectedCacheBlock))
        ret = FillStreamCachedBlock(selectedCacheBlock, 0x10000);
    else
        ret = FillChunkCachedBlock(selectedCacheBlock);
    
    if (cacheResult != NULL)
        *cacheResult = ret;
    
    if (ret >= 0)
    {
        if (!IsStreamed(selectedCacheBlock))
            selectedCacheBlock->Atomic_ReleaseWriteAccess();
        
        return selectedCacheBlock;
    }

    m_Mutex.Lock();
    ReinitCachedBlock(selectedCacheBlock, -1);
    selectedCacheBlock->Atomic_ReleaseReadAccess();
    selectedCacheBlock->Atomic_ReleaseWriteAccess();
    m_Mutex.Unlock();
    
    return NULL;
}

对于先前抛出的问题,如果N+3请求是通过 AcquireCachedBlock() 拿到了0号CacheBlock,那必须要要满足 CacheBlock的block id和请求的一致,但是根据收集到的信息,错误数据和正确数据不在一个block当中,因此N+3请求一定是从 AcquireAndPrefillUnusedBlock() 获得了0号CacheBlock。

AcquireAndPrefillUnusedBlock()拿到0号CacheBlock要满足怎样的条件呢?

创建新块的分支可以果断排除掉,然后考虑为什么没有选用1号CacheBlock偏偏是0号呢,1号CacheBlock既没有reader也没有writer计数,没有选中1号块的原因,只有一个! 那就是0号块此时此刻也没有reader和writer计数!根据TimeStamp得规则,优先使用旧得那块,1号块在N+2请求中刚被使用过。因此得出问题的结论,要让N+3号请求获得0号CacheBlock的条件是0号此时此刻reader和writer计数为0

为什么可以得出1号CacheBlock既没有reader也没有writer计数得判断?因为这个CacheBlock当前不在使用中,使用生命周期不会超出ReadBlock函数,同步访问有在AsyncReadManagerThreaded::SyncRequest()中有一个大锁,是无法通过同步方式重入的,异步方式也只有当前线程,所以可以得到这样的判断。

顺着这个结论继续反推,

接下来就是这个Bug的终极问题了

N+1号请求拿到了0号CacheBlock之后,内存拷贝前,哪里会产生reader和writer计数为0的情况?

N+3请求判断0号CacheBlock可用的时机,还处在 m_Mutex 上锁的状态, 因此搜索范围进一步缩小,问题变成了从 m_Mutex.Unlock()之后,到ReadBlock()中内存拷贝之前,哪里会产生reader和writer计数为0的情况?

通过对 AcquireCachedBlock() 和 AcquireAndPrefillUnusedBlock() 得行为分析,可以知道这两个函数如果返回有效的CacheBlock,那这个CacheBlock的reader计数是大于零的。范围进一步缩小,问题变成了:从m_Mutex.Unlock()之后到函数返回之前,何时reader和writer计数都为零?

目标范围锁定的非常小了,这样的地方只找到一处,AcquireCachedBlock() 函数中,if(!IsStreamed(requestBlock))判断成立时,m_Mutex.Unlock() 和 下一行 cache->Atomic_AcquireReadAccess() 之间。

Mutex::Unlock的函数中实际功能执行过后到lock inc指令之间存在间隙,只要lock inc执行之前线程的执行权被其他线程抢占,CacheBlock就有可能出现问题。

从AcquireCachedBlock()成功获取CacheBlock的条件就是请求的的block已经存在。

知道这一点,关于之前请求顺序的假设就自洽了。

到这一步,所有的线索都被串起来了,拼图完成了。

接下来就是通过重现验证这个bug的存在了。

虽然从bug有理论依据到成功复现经历了一波三折,但期间从来没有怀疑过推断的正确性。

期间尝试过不少方案都不成功,因为bug的触发条件有点复杂:

  1. 让ArchiveStorageRead产生两个CacheBlock
  2. 预热一个128K LZ4 CacheBlock
  3. 加载这个CacheBlock的另一块CachedReader 7KB 缓存
  4. 并且要在AcquireCachedBlock()中的m_Mutex.Unlock()上让线程停住
  5. 然后要触发两个以上的AUP请求覆盖掉CacheBlock的数据
  6. 停住的线程放行

首先第一条就是个难题,要解决第一条就要控制两个线程的执行,要控制线程执行就需要合适的断点,合适的断点就需要高度可控的资源加载,高度可控的资源加载依赖高度可控的AssetBundle数据布局。

斯文的办法不行,用暴力加载能解决吗?答案是几乎不能,PC的速度比手机快太多了,要让AUP加载和PreloadManager加载在ReadBlock()中遇到太难太难了。而且判断是否已经有两个CacheBlock本身已经是个问题了。

如何预热一块128K CacheBlock后再加载其中的另一块7KB?这也是个问题。

什么时机给AcquireCachedBlock()下断点也是个问题。

如何让PreloadManager已经被断住的情况下保证AUP有请求?

各种失败的方案就不列举了,这里说下最后成功的方案是如何实现的。

首先说下目标,要保证这个Bug稳定的重现,尽量减少人为干预,减少随机性,尽量避免调试器。

针对条件1,在诸多失败之后,笔者意识到一件事,事先存在两块CacheBlock并不是必须的条件,从原理出发,如果事先存在n个CacheBlock那就需要n个AUP请求来实现数据覆盖。如果事先只有一个CacheBlock,就只需要一个AUP请求就可以了,这个条件就可以去掉了。

针对2和3,通过构造资源来解决,先创建ScriptableObject,上面声明一个数组,随便填点数据,然后生成很多个实例,打到一个包中,然后挑一个处于128K块中靠前的对象,然后调整其数组长度,使得这个资源读取完时刚好对齐到7K。这个资源用来预热128K CacheBlock,然后再使用紧随其后的那个资源用来触发PreloadManager线程对ArchiveStorageRead的同步访问。

针对4,通过给Mutex::Unlock() 和 AcquireCachedBlock()挂钩子实现,在预热时,通过AcquireCachedBlock函数的钩子,获得PreloadManager的线程ID。然后触发读取下个7K之前,置一个变量告诉AcquireCachedBlock函数的钩子,下一次被调用到时,设置一个开关,如果这个开关被设置,那么下一次Mutex::Unlock被调用后,Mutex::Unlock的钩子会让线程Sleep 10秒。

针对5,换了一种思路,异步的读取并不是只有AUP能用,可以在10秒期间通过C#的API AsyncReadManager.Read()来模拟AUP读取的情况。

如此一来,Bug触发就变得高度可控了。如此一来就顺利重现了吗?

当然不是。

经过调试后发现Unity会在Update中判断PreloadManager的队列是空的话,就强制关闭所有文件句柄,这个机制会导致128K CacheBlock预热到加载下个7K Cache的步骤变得不稳定。最后通过给 PreloadManager::IsLoadingOrQueued函数挂钩子强制返回true解决了。


重现崩溃的 Call Stack

Exception thrown at 0x0000000142B67AD0 in Unity.exe: 0xC0000005: Access violation reading location 0x0000000052840000.
Crash 在这种情况下却是一件喜事。

2019.3.17:

Bug已经提给Unity,由于问题出在引擎,最好的办法就是等官方修复。着急的同学可以考虑改二进制调整下语序。

2019.3.31:

Unity QA Team通过bug report复现了崩溃,已转交开发团队。

2019.4.23:

Bug report中原理都解释了,跟QA斡旋几个回合后,愚蠢的QA最终上报的时候认为这只是一个editor crash。

Unity IssueTracker - Editor crash when calling StreamedBinaryRead::TransferSTLStyleArrayissuetracker.unity3d.com

着急的同学赶紧去vote吧(可以顺便吐个槽)。我司自行修补后崩溃率降了几个百分点。

2019.5.16:

从Unity工作人员处得知在主线版本已经修复此Bug,目前正在backport到老版本上。预计下个发行版会修复这个陈年Bug。

2019.5.29:

Unity 2018.4.1 发布了.

Asset Pipeline: Fixed crash/data corruption when loading multiple asset bundles concurrently. (1140019, 1148846)

unity3d.com/unity/whats

编辑于 2019-05-29

文章被以下专栏收录