cocos2d-x游戏引擎核心之八——多线程
一、多线程原理
(1)单线程的尴尬
重新回顾下 Cocos2d-x 的并行机制。引擎内部实现了一个庞大的主循环,在每帧之间更新各个精灵的状态、执行动作、调用定时函数等,这些操作之间可以保证严格独立,互不干扰。不得不说,这是一个非常巧妙的机制,它用一个线程就实现了并发,尤其是将连续的动作变化切割为离散的状态更新时,利用帧间间隔刷新这些状态即实现了多个动作的模拟。
但这在本质上毕竟是一个串行的过程,一种尴尬的场景是,我们需要执行一个大的计算任务,两帧之间几十毫秒的时间根本不可能完成,例如加载几十张图片到内存中,这时候引擎提供的 schedule 并行就显得无力了:一次只能执行一个小时间片,我们要么将任务进一步细分为一个个更小的任务,要么只能眼睁睁地看着屏幕上的帧率往下掉,因为这个庞大计算消耗了太多时间,阻塞了主循环的正常运行。
本来这个问题是难以避免的,但是随着移动设备硬件性能的提高,双核甚至四核的机器已经越来越普遍了,如果再不通过多线程挖掘硬件潜力就过于浪费了。
(2)pthead
pthread 是一套 POSIX 标准线程库,可以运行在各个平台上,包括 Android、iOS 和 Windows,也是 Cocos2d-x 官方推荐的多线程库。它使用 C 语言开发,提供非常友好也足够简洁的开发接口。一个线程的创建通常是这样的:
void* justAnotherTest(void *arg) { LOG_FUNCTION_LIFE; //在这里写入新线程将要执行的代码 return NULL; } void testThread() { LOG_FUNCTION_LIFE; pthread_t tid; pthread_create(&tid, NULL, &justAnotherTest, NULL); }
这里我们在testThread函数中用pthread_create创建了一个线程,新线程的入口为justAnotherTest函数。pthread_create函数的代码如下所示:
PTW32_DLLPORT int PTW32_CDECL pthread_create (pthread_t * tid,//线程的标示 const pthread_attr_t * attr, //创建线程的参数 void *(*start) (void *), //入口函数的指针 void *arg); //传递给线程的数据
pthread_create 是创建新线程的方法,它的第一个参数指定一个标识的地址,用于返回创建的线程标识;第二个参数是创建线程的参数,在不需要设置任何参数的情况下,只需传入 NULL 即可;第三个参数则是线程入口函数的指针,被指定为 void*(void*)的形式。函数指针接受的唯一参数来源于调用 pthread_create 函数时所传入的第四个参数,可以用于传递用户数据。
(3)线程安全
使用线程就不得不提线程安全问题。线程安全问题来源于不同线程的执行顺序是不可预测的,线程调度都视系统当时的状态而定,尤其是直接或间接的全局共享变量。如果不同线程间都存在着读写访问,就很可能出现运行结果不可控的问题。
在 Cocos2d-x 中,最大的线程安全隐患是内存管理。引擎明确声明了 retain、release 和 autorelease 三个方法都不是线程安全的。如果在不同的线程间对同一个对象作内存管理,可能会出现严重的内存泄露或野指针问题。比如说,如果我们按照下述代码加载图片资源,就很可能出现找不到图片的报错——可能出现这样的情况,当主线程执行到CCSprite::Create创建精灵的时候,上面的线程还没有执行或者没有执行完成图片资源的加载,这时就可能出现找不到图片。
void* loadResources(void *arg) { LOG_FUNCTION_LIFE; CCTextureCache::sharedTextureCache()->addImage("fish.png"); return NULL; } void makeAFish() { LOG_FUNCTION_LIFE; pthread_t tid; pthread_create(&tid, NULL, &loadResources, NULL); CCSprite* sp = CCSprite::create("fish.png"); }
在新的线程中对缓存的调用所产生的一系列内存管理操作更可能导致系统崩溃。
因此,使用多线程的首要原则是,在新建立的线程中不要使用任何 Cocos2d-x 内建的内存管理,也不要调用任何引擎提供的函数或方法,因为那可能会导致 Cocos2d-x 内存管理错误。
同样,OpenGL 的各个接口函数也不是线程安全的。也就是说,一切和绘图直接相关的操作都应该放在主线程内执行,而不是在新建线程内执行。(见第六点cocos2dx内存管理与多线程问题)
(4)线程间任务安排
使用并发编程的最直接目的是保证界面流畅,这也是引擎占据主线程的原因。因此,除了界面相关的代码外,其他操作都可以放入新的线程中执行,主要包括文件读写和网络通信两类。
文件读写涉及外部存储操作,这和内存、CPU 都不在一个响应级别上。如果将其放入主线程中,就可能会造成阻塞,尤为严重的是大型图片的载入。对于碎图压缩后的大型纹理和高分辨率的背景图,一次加载可能耗费 0.2 s 以上的时间,如果完全放在主线程内,会阻塞主线程相当长的时间,导致画面停滞,游戏体验很糟糕。在一些大型的卷轴类游戏中,这类问题尤为明显。考虑到这个问题,Cocos2d-x 为我们提供了一个异步加载图片的接口,不会阻塞主线程,其内部正是采用了新建线程的办法。
我们用游戏中的背景层为例,原来加载背景层的操作是串行的,相关代码如下:
bool BackgroundLayer::init() { LOG_FUNCTION_LIFE; bool bRet = false; do { CC_BREAK_IF(! CCLayer::init()); CCSize winSize = CCDirector::sharedDirector()->getWinSize(); CCSprite *bg = CCSprite::create ("background.png"); CCSize size = bg->getContentSize(); bg->setPosition(ccp(winSize.width / 2, winSize.height / 2)); float f = max(winSize.width / size.width, winSize.height / size.height); bg->setScale(f); this->addChild(bg); bRet = true; } while (0); return bRet; }
现在我们将这一些列串行的过程分离开来,使用引擎提供的异步加载图片接口异步加载图片,相关代码如下:
void BackgroundLayer::doLoadImage(ccTime dt) { CCSize winSize = CCDirector::sharedDirector()->getWinSize(); CCSprite *bg = CCSprite::create("background.png"); CCSize size = bg->getContentSize(); bg->setPosition(ccp(winSize.width / 2, winSize.height / 2)); float f = max(winSize.width/size.width,winSize.height/size.height); bg->setScale(f); this->addChild(bg); } void BackgroundLayer::loadImageFinish(CCObject* sender) { this->scheduleOnce(schedule_selector(BackgroundLayer::doLoadImage), 2); } bool BackgroundLayer::init() { LOG_FUNCTION_LIFE; bool bRet = false; do { CC_BREAK_IF(! CCLayer::init()); CCTextureCache::sharedTextureCache()->addImageAsync( "background.png", this, callfuncO_selector(BackgroundLayer::loadImageFinish)); bRet = true; } while (0); return bRet; }
为了加强效果的对比,我们在图片加载成功后,延时了 2 s,而后才真正加载背景图片到背景层中。读者可以明显看到,2s后游戏中才出现了背景图。尽管引擎已经为我们提供了异步加载图片缓存的方式,但考虑到对图片资源的加密解密过程是十分耗费计算资源的,我们还是有必要单开一个线程执行这一系列操作。另一个值得使用并发编程的是网络通信。网络通信可能比文件读写要慢一个数量级。一般的网络通信库都会提供异步传输形式,我们只需要注意选择就好。
(5)线程同步
使用了线程,必然就要考虑到线程同步,不同的线程同时访问资源的话,访问的顺序是不可预知的,会造成不可预知的结果。查看addImageAsync的实现源码可以知道它是使用pthread_mutex_t来实现同步:
void CCTextureCache::addImageAsync(const char *path, CCObject *target, SEL_CallFuncO selector) { CCAssert(path != NULL, "TextureCache: fileimage MUST not be NULL"); CCTexture2D *texture = NULL; // optimization std::string pathKey = path; pathKey = CCFileUtils::sharedFileUtils()->fullPathFromRelativePath(pathKey.c_str()); texture = (CCTexture2D*)m_pTextures->objectForKey(pathKey.c_str()); std::string fullpath = pathKey; if (texture != NULL) { if (target && selector) { (target->*selector)(texture); } return; } // lazy init if (s_pSem == NULL) { #if CC_ASYNC_TEXTURE_CACHE_USE_NAMED_SEMAPHORE s_pSem = sem_open(CC_ASYNC_TEXTURE_CACHE_SEMAPHORE, O_CREAT, 0644, 0); if( s_pSem == SEM_FAILED ) { CCLOG( "CCTextureCache async thread semaphore init error: %s\n", strerror( errno ) ); s_pSem = NULL; return; } #else int semInitRet = sem_init(&s_sem, 0, 0); if( semInitRet < 0 ) { CCLOG( "CCTextureCache async thread semaphore init error: %s\n", strerror( errno ) ); return; } s_pSem = &s_sem; #endif s_pAsyncStructQueue = new queue<AsyncStruct*>(); s_pImageQueue = new queue<ImageInfo*>(); pthread_mutex_init(&s_asyncStructQueueMutex, NULL); pthread_mutex_init(&s_ImageInfoMutex, NULL); pthread_create(&s_loadingThread, NULL, loadImage, NULL); need_quit = false; } if (0 == s_nAsyncRefCount) { CCDirector::sharedDirector()->getScheduler()->scheduleSelector(schedule_selector(CCTextureCache::addImageAsyncCallBack), this, 0, false); } ++s_nAsyncRefCount; if (target) { target->retain(); } // generate async struct AsyncStruct *data = new AsyncStruct(); data->filename = fullpath.c_str(); data->target = target; data->selector = selector; // add async struct into queue pthread_mutex_lock(&s_asyncStructQueueMutex); s_pAsyncStructQueue->push(data); pthread_mutex_unlock(&s_asyncStructQueueMutex); sem_post(s_pSem); }
二、应用实例一——cococs2d-x 多线程加载plist
【转自】 http://blog.csdn.net/we000636/article/details/8641270
(1)环境搭建
当我们想在程序中开多线程中,第一想到的是cocos2d-x有没有自带方法,幸运的是我们找到了CCThread,不幸却发现里面什么都没有。cocos2d-x自带了一个第三方插件--pthread,在cocos2dx\platform\third_party\win32\pthread可以找到。既然是自带的,必须它的理由。想在VS中应用这个插件需要两个步骤:
1.需要右键工程--属性--配置属性--链接器--输入--编缉右侧的附加依赖项--在其中添加pthreadVCE2.lib,如下图所示:
2..需要右键工程--属性--配置属性--C/C++--常规--编缉右侧的附加包含目录--添加新行--找到pthread文件夹所在位置,如下图所示:
然后我们就可以应用这个插件在程序中开启新线程,简单线程开启方法如下代码所示:
#ifndef _LOADING_SCENE_H__ #define _LOADING_SCENE_H__ #include "cocos2d.h" #include "pthread/pthread.h" class LoadingScene : public cocos2d::CCScene{ public: virtual bool init(); CREATE_FUNC(LoadingScene); int start(); void update(float dt); private: pthread_t pid; static void* updateInfo(void* args); //注意线程函数必须是静态的 };
#include "LoadingScene.h" #include "pthread/pthread.h" using namespace cocos2d; bool LoadingScene::init(){ this->scheduleUpdate(); start(); return true; } void LoadingScene::update(float dt){ //可以在这里重绘UI } void* LoadingScene::updateInfo(void* args){ //可以在这里加载资源 return NULL; } int LoadingScene::start(){ pthread_create(&pid,NULL,updateInfo,NULL); //开启新线程 return 0; }
(2)加载plist
我们可以在新开的线程中,加载资源,设置一个静态变量bool,在新线程中,当加载完所有资源后,设置bool值为真。在主线程中Update中,检测bool值,为假,可以重绘UI(例如,显示加载图片,或者模拟加载进度),为真,则加载目标场景。相关代码如下:
void* LoadingScene::updateInfo(void* args){ CCSpriteFrameCache *cache = CCSpriteFrameCache::sharedSpriteFrameCache(); cache->addSpriteFramesWithFile("BattleIcons.plist"); cache->addSpriteFramesWithFile("ArcherAnim.plist"); cache->addSpriteFramesWithFile("DeathReaperAnim.plist"); loadComplete = true; //状态值设为真,表示加载完成 return NULL; }
成功加载且运行后,你会发现新场景中所有精灵都不显示(类似于黑屏了)。为什么呢?
因为我们在加载plist文件时,addSpriteFramesWithFile方法里会帮我们创建plist对应Png图的Texture2D,并将其加载进缓存中。可是这里就遇到了一个OpenGl规范的问题:不能在新开的线程中,创建texture,texture必须在主线程创建.通俗点,就是所有的opengl api都必须在主线程中调用;其它的操作,比如文件,内存,plist等,可以在新线程中做,这个不是cocos2d不支持,是opengl的标准,不管你是在android,还是windows上使用opengl,都是这个原理。
所以不能在新线程中创建Texture2D,导致纹理都不显示,那么该怎么办?让我们看看CCSpriteFrameCache源码,发现CCSpriteFrameCache::addSpriteFramesWithFile(const char *pszPlist, CCTexture2D *pobTexture)方法,是可以传入Texture2D参数的。是的,我们找到了解决方法:
int LoadingScene::start(){ CCTexture2D *texture = CCTextureCache::sharedTextureCache()->addImage("BattleIcons.png"); //在这里(主线程中)加载plist对应的Png图片进纹理缓存 CCTexture2D *texture2 = CCTextureCache::sharedTextureCache()->addImage("ArcherAnim.png"); //以这种方法加载的纹理,其Key值就是文件path值,即例如 texture2的key值就是ArcherAnim.png CCTexture2D *texture3 = CCTextureCache::sharedTextureCache()->addImage("DeathReaperAnim.png"); pthread_create(&pid,NULL,updateInfo,NULL); //开启新线程 return 0; } void* LoadingScene::updateInfo(void* args){ CCSpriteFrameCache *cache = CCSpriteFrameCache::sharedSpriteFrameCache(); CCTextureCache* teCache = CCTextureCache::sharedTextureCache(); CCTexture2D* texture1 = teCache->textureForKey("BattleIcons.png"); //从纹理缓存中取出Texure2D,并将其当参数传入addSpriteFramesWithFile方法中 cache->addSpriteFramesWithFile("BattleIcons.plist",texture1); CCTexture2D* texture2 = teCache->textureForKey("ArcherAnim.png"); cache->addSpriteFramesWithFile("ArcherAnim.plist",texture2); CCTexture2D* texture3 = teCache->textureForKey("DeathReaperAnim.png"); cache->addSpriteFramesWithFile("DeathReaperAnim.plist",texture3); loadComplete = true; return NULL; }
这样解决,就不违背OpenGl规范,没有在新线程中创建Texture2D。
Tip:OpenGL与线程相结合时,此时你需要把你需要渲染的精灵先加载到内存中去,可以设置成为不显示,然后在线程执行后再设置精灵成显示状态,这样可以解决线程与OpneGL渲染不兼容的问题
二、应用实例二——Cocos2d-x 3.0多线程异步资源加载
【转自】http://tonybai.com/2014/04/28/multithreaded-resource-loading-in-cocos2dx-3/
// AppDelegate.cpp bool AppDelegate::applicationDidFinishLaunching() { … … FlashScene* scene = FlashScene::create(); pDirector->runWithScene(scene); return true; }
在FlashScene init时,我们创建一个Resource Load Thread,我们用一个ResourceLoadIndicator作为渲染线程与Worker线程之间交互的媒介。
//FlashScene.h struct ResourceLoadIndicator { pthread_mutex_t mutex; bool load_done; void *context; }; class FlashScene : public Scene { public: FlashScene(void); ~FlashScene(void); virtual bool init(); CREATE_FUNC(FlashScene); bool getResourceLoadIndicator(); void setResourceLoadIndicator(bool flag); private: void updateScene(float dt); private: ResourceLoadIndicator rli; }; // FlashScene.cpp bool FlashScene::init() { bool bRet = false; do { CC_BREAK_IF(!CCScene::init()); Size winSize = Director::getInstance()->getWinSize(); //FlashScene自己的资源只能同步加载了 Sprite *bg = Sprite::create("FlashSceenBg.png"); CC_BREAK_IF(!bg); bg->setPosition(ccp(winSize.width/2, winSize.height/2)); this->addChild(bg, 0); this->schedule(schedule_selector(FlashScene::updateScene) , 0.01f); //start the resource loading thread rli.load_done = false; rli.context = (void*)this; pthread_mutex_init(&rli.mutex, NULL); pthread_attr_t attr; pthread_attr_init(&attr); pthread_attr_setdetachstate(&attr, PTHREAD_CREATE_DETACHED); pthread_t thread; pthread_create(&thread, &attr, resource_load_thread_entry, &rli); bRet=true; } while(0); return bRet; } static void* resource_load_thread_entry(void* param) { AppDelegate *app = (AppDelegate*)Application::getInstance(); ResourceLoadIndicator *rli = (ResourceLoadIndicator*)param; FlashScene *scene = (FlashScene*)rli->context; //load music effect resource … … //init from config files … … //load images data in worker thread SpriteFrameCache::getInstance()->addSpriteFramesWithFile( // 函数内部会进行纹理创建,不能再非主线程中调用cocos2dx内部函数或egl图形api "All-Sprites.plist"); … … //set loading done scene->setResourceLoadIndicator(true); return NULL; } bool FlashScene::getResourceLoadIndicator() { bool flag; pthread_mutex_lock(&rli.mutex); flag = rli.load_done; pthread_mutex_unlock(&rli.mutex); return flag; } void FlashScene::setResourceLoadIndicator(bool flag) { pthread_mutex_lock(&rli.mutex); rli.load_done = flag; pthread_mutex_unlock(&rli.mutex); return; }
我们在定时器回调函数中对indicator标志位进行检查,当发现加载ok后,切换到接下来的游戏开始场景:
void FlashScene::updateScene(float dt) { if (getResourceLoadIndicator()) { Director::getInstance()->replaceScene( WelcomeScene::create()); } }
到此,FlashScene的初始设计和实现完成了。Run一下试试吧。
threadid=24: thread exiting, not yet detached (count=0) threadid=24: thread exiting, not yet detached (count=1) threadid=24: native thread exited without detaching
很是奇怪啊,我们在创建线程时,明明设置了 PTHREAD_CREATE_DETACHED属性了啊:
pthread_attr_setdetachstate(&attr, PTHREAD_CREATE_DETACHED);
#if (CC_TARGET_PLATFORM == CC_PLATFORM_ANDROID) #include "platform/android/jni/JniHelper.h" #include <jni.h> #endif static void* resource_load_thread_entry(void* param) { … … JavaVM *vm; JNIEnv *env; vm = JniHelper::getJavaVM(); JavaVMAttachArgs thread_args; thread_args.name = "Resource Load"; thread_args.version = JNI_VERSION_1_4; thread_args.group = NULL; vm->AttachCurrentThread(&env, &thread_args); … … //Your Jni Calls … … vm->DetachCurrentThread(); … … return NULL; }
关于什么是JavaVM,什么是JniEnv,Android Developer官方文档中是这样描述的
The JavaVM provides the "invocation interface" functions, which allow you to create and destroy a JavaVM. In theory you can have multiple JavaVMs per process, but Android only allows one.
上面的代码成功解决了线程崩溃的问题,但问题还没完,因为接下来我们又遇到了“黑屏”事件。所谓的“黑屏”,其实并不是全黑。但进入游戏 WelcomScene时,只有Scene中的LabelTTF实例能显示出来,其余Sprite都无法显示。显然肯定与我们在Worker线程加载纹理资源有关了:
libEGL: call to OpenGL ES API with no current context (logged once per thread)
static void* resource_load_thread_entry(void* param) { … … allSpritesImage = new Image(); allSpritesImage->initWithImageFile("All-Sprites.png"); … … } void FlashScene::updateScene(float dt) { if (getResourceLoadIndicator()) { // construct texture with preloaded images Texture2D *allSpritesTexture = TextureCache::getInstance()-> addImage(allSpritesImage, "All-Sprites.png"); allSpritesImage->release(); SpriteFrameCache::getInstance()->addSpriteFramesWithFile( "All-Sprites.plist", allSpritesTexture); Director::getInstance()->replaceScene(WelcomeScene::create()); } }
完成这一修改后,游戏画面就变得一切正常了,多线程资源加载机制正式生效。
------------------------------------------------------------------------------------------------
(6)cocos2dx内存管理与多线程问题
【转自】http://blog.csdn.net/kaitiren/article/details/14453313
Cocos2d-x的内存管理采用Objective-C的机制,大喜过望。因为只要坚持Objective-C的原则“谁创建谁释放,谁备份谁释放”的原则即可确保内存使用不易出现Bug。
但是因为游戏需要使用到多线程技术,导致测试的时候总是莫名其妙的导致空指针错误。而且是随机出现,纠结了2天无果后,开始怀疑Cocos2d-X的内
存本身管理可能存在问题。怀着这样的想法,一步一步的调试,发现经常出现指针异常的变量总是在调用autorelease一会后,再使用的时候就莫名其妙
抛异常。狠下心,在它的析构函数里面断点+Log输出信息。发现对象被释放了。一时也很迷糊,因为对象只是autorelease,并没有真正释放,是谁
导致它释放的?
然后就去看了CCAutoreleasePool的源码,发现Cocos2d-X的内存管理在多线程的情况下存在如下问题:
如图:thread 1和thread 2是独立的两个线程,它们之间存在CPU分配的交叉集,我们在time 1的时候push一个autorelease的自动释放池,在该线程的末尾,即time 3的时候pop它。同理在thread 2的线程里面,在time 2的时候push一个自动释放池,在time 4的时候释放它,即Pop.
此时我们假设在thread 2分配得到CPU的时候有一个对象obj自动释放(在多线程下,这种情况是有可能发生的,A线程push了一个对象,而B线程执行autorelease时,会把A线程的对象提前释放), 即obj-autorelease().那么在time 3的时候会发生是么事情呢?答案很简单,就是obj在time 3的时候就被释放了,而我们期望它在time 4的时候才释放。所以就导致我上面说的,在多线程下面,cocos2d-x的autorelease变量会发生莫名其妙的指针异常。
解决方法:在PoolManager给每个线程根据pthread_t的线程id生成一个CCArray的stack的嵌套管理自动释放池。在Push的时 候根据当前线程的pthread_t的线程id生成一个CCArray的stack来存储该线程对应的Autoreleasepool的嵌套对象。