1. 程式人生 > >cocos2d-x遊戲引擎核心之多執行緒分析及注意事項

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