[译文]5分钟系列—快速理解Floating-point Numbers(浮点数)

原文链接:What Are Floating-point Numbers?

前言

浮点数是一种以二进制形式存储数字的方法, 它允许我们使用固定数量的存储空间来表示”大范围的值”

为什么要了解浮点数?

了解了浮点数,以下两个问题你就能马上明白

  • 为什么 0.1 + 0.2 在某些情况下并不等于 0.3
  • 如何用二进制的形式存储小数( non-integer)

5分钟带你了解浮点数

首先,我们来看看二进制是怎么存储整数的。从低到高(右到左)每一位都表示 2 的幂 , 通过按权展开求和,我们能得到想要的整数,示例如下:

二进制表示整型

二进制表示整数,完美。但是怎么用二进制表示小数呢?比如:2.5

聪明的你肯定想到了,把二进制位分为两部分。左边部分表示小数点前面的数字(示例中的2),右边的部分表示小数点后面的数字(示例中的0.5),于是上图变为如下:

二进制表示非整型

看似解决,但仍然还是有很多小数没法表示,比如: 2.36 就没法表示,最接近的小数是2.375(用计算机术语就是:缺失精度),甚至我们都无法用它表示16.0( 因为左边只有四位,最大值只能表示15)

我们确实可以通过扩展位数用来增加所表示的小数的“大小”和“精度”,但是这样做不够灵活。有时候我们想存储值很大的小数,我们就希望左侧有更多位数;有时候我们想存储尾数(小数点后的数字)很大的小数,就肯定希望右边有更多的位数。那有没有一种方法,能动态满足我们的需求呢?它就是浮点数

浮点数的国际标准是:IEEE 754 ,它定义了32位和64位浮点值;我们用32位浮点值来举例,它的二进制结构如下(从左到右):

  • 第一位代表”符号(Sign)”; 0 表示整数,1表示负数
  • 接下来的8位代表”指数(Exponent)”
  • 最后的23位代表”尾数(Mantissa)”

我们在一个公式中,用到这三个值,公式计算的结果就是实际表示的数字:

你不用理解这个公式的具体运作方式,只要知道这种方式可以让我们更灵活更自由的用二进制表达小数。这也是为什么称之为:”浮点”, 它不像我们上面的示例,小数点在中间,相反它能通过调节指数从而移动小数点的位置,是不是很神奇?

舍入误差

有些分数,其实我们也无法用小数来准确表示。比如: ⅓ = 0.33333333333333…

同理,有些小数二进制也没法精确表示。比如:让我们尝试用二进制表示 0.1,大概如下:

我们可以通过减小指数让它无限接近0.1,但始终没法精确到刚好是0.1。这也是为什么你用chrome浏览器(按F12),在Console 输入: 0.1+0.2 回车,得出的结果是: 0.30000000000000004

因此但是考虑到可能会有这种结果,我们在比较浮点数是否相等的时候,方式2 比较合理:

float result1, result2;
#define RoundingValue = 0.0001f
//方式1:这种写法非常不推荐
if(result1 == result2)
{
    //do some thing 
}

//方式2:推荐写法
if(Math.abs(result1 - result2) < RoundingValue )
{
    //do some thing
}

拓展链接:

[原创]UE—GamePlay启动流程

趁着国庆假期把 GamePlay 框架又梳理了一番, 遂尝试将梳理的脉络用文字表达出来, 借此加深理解, 倘若能为他人解惑那更是善莫大焉; 当前引擎版本: UnrealEngine4.25.3

游戏引擎的GamePlay框架伪代码基本如下:

int main()
{
    init();
    while(!exit_requested)
    {
        update(); //input,gamelogic,render...
    }
    exit();
}
//CodeSnippet.1

UE的GamePlay框架也是遵循这个伪代码展开; 我们打开UE的引擎源码, 从 GuardedMain() 函数开始 (为了便于理解流程,本文所有代码段都对源码进行一定的删改 )

//Launch.cpp 
int32 GuardedMain(const TCHAR* CmdLine)
{
    struct EngineLoopCleanupGuard 
    { 
       ~EngineLoopCleanupGuard() 
       {
          GEngineLoop.Exit(); 
       }
    } CleanupGuard;
 
    //run early Initialization,load engine modules
    GEngineLoop.PreInit(CmdLine);
 
    //Create and initizlize a UEngine, run late Initialization,start the game
    GEngineLoop.Init();
 
    //kick off rendering,tick the engine,update RHI
    while(!IsEngineExitRequested())
    {
	 GEngineLoop.Tick();
    }
} 
//CodeSnippet.2

CodeSnippet.2 和 CodeSnippet.1 的区别就是退出函数。 GuardedMain() 定义了一个局部变量 CleanupGuard,在函数结束以后系统会调用 ~EngineLoopCleanupGuard(), 从而退出游戏

GEngineLoop.PreInit() 函数的主要内容就是按照顺序加载游戏相关的引擎、项目和第三方插件等模块, 代码如下:

//LaunchEngineLoop.cpp
int32 FEngineLoop::PreInit(const TCHAR* CmdLine)
{
    LoadCoreModules(); //CoreUObject
    LoadPreInitModules(); //Engine,Renderer,AnimGraphRuntime,RenderAPI...
    LoadCustomModules(); //EarliestPossible,PostConfigInit,PostSplashScreen...
    LoadStartupCoreModules(); //Core,Networking,SlateCore,Slate,Overlay...
    ...
    return 0;
}
//CodeSnippet.3

加载完当前模块会进行模块注册等相关内容(CDO也是在此阶段生成), 预初始化执行完就开始初始化,CodeSnippet.2 的 GEngineLoop.Init() 就会被执行,代码如下:

//LaunchEngineLoop.cpp
int32 FEngineLoop::Init()
{
    FString GameEngineClassName;
    GConfig->GetString(TEXT("/Script/Engine.Engine"), TEXT("GameEngine"), GameEngineClassName, GEngineIni);
    UClass* EngineClass = StaticLoadClass( UGameEngine::StaticClass(), nullptr, *GameEngineClassName);
   
    //create UGameEngine
    GEngine = NewObject<UEngine>(GetTransientPackage(), EngineClass);
    check( GEngine );
   
    //create GameInstance; Client: create ViewpointClient and LocalPlayer
    GEngine->Init(this);
    UEngine::OnPostEngineInit.Broadcast();
    FCoreDelegates::OnPostEngineInit.Broadcast();
   
    //initialize any late-loaded modules(加载哪些设置在postEngineinit阶段才加载的模块)
    IProjectManager::Get().LoadModulesForProject(ELoadingPhase::PostEngineInit);
    IPluginManager::Get().LoadModulesForEnabledPlugins(ELoadingPhase::PostEngineInit));

    //start the game,typically this load the default map
    GEngine->Start();
    GIsRunning = true;
    FCoreDelegates::OnFEngineLoopInitComplete.Broadcast();
    return 0;
}
//CodeSnippet.4

本文分析的是非编辑器环境,因此 创建的 UEngine 就是 UGameEngine, 创建完就执行初始化 GEngine->Init(this); 代码如下:

//GameEngine.cpp
void UGameEngine::Init(IEngineLoop* InEngineLoop)
{
    //create GameInstance
    FSoftClassPath GameInstanceClassName = GetDefault<UGameMapsSettings>()->GameInstanceClass;
    UClass* GameInstanceClass = LoadObject<UClass>(NULL, *GameInstanceClassName.ToString()));
    GameInstance = NewObject<UGameInstance>(this, GameInstanceClass);
 
    //create FWorldContext and dummy World,Init Subsystem
    GameInstance->InitializeStandalone();
	
    //Create ans Initialize the viewport client
    if(GIsClient)
    {
        ViewportClient = NewObject<UGameViewportClient>(this, GameViewportClientClass);
        ViewportClient->Init(*GameInstance->GetWorldContext(), GameInstance);
        GameViewport = ViewportClient;
        GameInstance->GetWorldContext()->GameViewport = ViewportClient;
        CreateGameViewport(ViewportClient);

        //Create a ULocalPlayer and associate it with viewpoint client
        FString Error;
        ViewportClient->SetupInitialLocalPlayer(Error);
        UGameViewportClient::OnViewportCreated().Broadcast();
    }
}
//CodeSnippet.5

CodeSnippet.5 主要就是创建 UGameInstance 并初始化。 此时开始出现 Client 和 Server ( 本文特指 Dedicated Server,暂不考虑 Listen Server) 的区别

Server

CodeSnippet.5 执行完毕,Server 端拥有的对象: UGameEngine , UGameInstance , UWorldContext以及创建的一个临时世界 ; 接下来就是执行 CodeSnippet.4 内的 GEngine->Start() , 它最终会调用 UEngine::LoadMap() 代码如下:

//UnrealEngine.cpp
bool UEngine::LoadMap( FWorldContext& WorldContext, FURL URL, class UPendingNetGame* Pending, FString& Error )
{
    //Let any interested parties konw that the current world is about to be unloaded
    FCoreUObjectDelegates::PreLoadMap.Broadcast(URL.Map);
   
    //Clear Current World
    // Destory player-controlled Pawns and PlayerControllers
    //Route the EndPlay event to all actors
    //Clean up the world, destory all actors
   
    //Notify the GameInstance of the map change in case it wants to load assets
    WorldContext.OwningGameInstance->PreloadContentForURL(URL);
   
    //Load our persistent level's map package and get top-level UWorld
    //UWorld,ULevel,Actor 等在编辑器已经生成好,打包的时候被序列化到了.umap文件内, 加载完反序列化就能拿到所有内容
    //也包括WordSettings对象(以及给它设置的各种对象配置)
    UPackage*  WorldPackage = LoadPackage(nullptr, *URL.Map, LOAD_None));;
    UWorld*    NewWorld = UWorld::FindWorldInPackage(WorldPackage);
   
    //Give the world a reference to the GameInstance
    NewWorld->SetGameInstance(WorldContext.OwningGameInstance);
    GWorld = NewWorld;
   
    //just-load UWorld and get it ready for gameplay
    WorldContext.SetCurrentWorld(NewWorld);
    WorldContext.World()->WorldType = WorldContext.WorldType;
    WorldContext.World()->AddToRoot();
    WorldContext.World()->InitWorld(); //物理,寻路导航,AI以及声音系统进行设置
    WorldContext.World()->SetGameMode(URL); //让GameInstance在世界生成GameMode,Server-Only
   
    //Load any per-map packages and make sure "always aloaded" sub-levels are fully loaded
    LoadPackagesFully(WorldContext.World(), FULLYLOAD_Map, WorldContext.World()->PersistentLevel->GetOutermost()->GetName());
    WorldContext.World()->FlushLevelStreaming(EFlushLevelStreamingType::Visibility);

    //Register components, then initialize all actors and their components
    {
       FRegisterComponentContext Context(WorldContext.World());
       WorldContext.World()->InitializeActorsForPlay(URL, true, &Context);
       Context.Process();
    }

    //Spawn play actors(i.e. PlayerControllers) for all active local players
    for(auto It = WorldContext.OwningGameInstance->GetLocalPlayerIterator(); It; ++It)
    {
        FString Error2;
        (*It)->SpawnPlayActor(URL.ToString(1),Error2,WorldContext.World());
    }
   
    //如果是DS或者监听服,那么创建Socket监听客户端请求
    if (Pending == NULL && (!GIsClient || URL.HasOption(TEXT("Listen"))))
    {
       if (!WorldContext.World()->Listen(URL))
       {
          UE_LOG(LogNet, Error, TEXT("LoadMap: failed to Listen(%s)"), *URL.ToString());
       }
    }

    //Route the BeginPlay event to indicate that all actors are present and initialized
    WorldContext.World()->BeginPlay();

    //let listeners konw that the map change is done
    PostLoadMapCaller.Broadcast(WorldContext.World());

    double StopTime = FPlatformTime::Seconds();
    UE_LOG(LogLoad, Log, TEXT("Took %f seconds to LoadMap(%s)"), StopTime - StartTime, *URL.Map);
    FLoadTimeTracker::Get().DumpRawLoadTimes();
    WorldContext.OwningGameInstance->LoadComplete(StopTime - StartTime, *URL.Map);

    // Successfully started local level.
    return true;
} 
//CodeSnippet.6

根据URL加载对应的 URL.ump , 然后将 URL.ump 内的所有对象反序列化出来。因此我们就有了新的UWorld,ULevel 以及场景内包含的 Actors

接下来执行的代码是 SetGameMode(URL) 它会创建一个很重要的对象: GameModeBase。它代表着游戏的规则,比如:游戏时长,玩家数量,输赢判定等

再接下来执行的代码是 InitializeActorsForPlay(URL, true, &Context) 它的作用是初始化当前关卡内所有Actors; 包括注册 Actors 的组件,执行 Actors 的三个初始化方法 PreInitializeComponents,InitializeComponents,PostInitializeComponents 等

GameModeBase 继承自 Actor , 因此它的 PreInitializeComponents 方法也会被调用, 代码如下:

//GameModeBase.cpp
void AGameModeBase::PreInitializeComponents()
{
    Super::PreInitializeComponents();
   
    //Spawn a GameState actor
    UWorld* World = GetWorld();
    FActorSpawnParameters SpawnInfo;
    SpawnInfo.Instigator = GetInstigator();
    SpawnInfo.ObjectFlags |= RF_Transient; // We never want to save game states or network managers into a map                            
    GameState = World->SpawnActor<AGameStateBase>(GameStateClass, SpawnInfo);
   
    //Associate the GameState with the world
    World->SetGameState(GameState);
    GameState->AuthorityGameMode = this;

    //spawn a GameNetWorkManager, Server Only
    AWorldSettings* WorldSettings = World->GetWorldSettings();
    World->NetworkManager = World->SpawnActor<AGameNetworkManager>(WorldSettings->GameNetworkManagerClass, SpawnInfo);
   
    //Initialize the GameState based upon the initial configuration of the GameMode
    InitGameState();
} 
//CodeSnippet.7

CodeSnippet.7 主要是生成 GameState , NetworkManager ; GameState 用来存储当前游戏模式的基础数据

Actor 在调用 InitializeComponents 方法时,如果Actor勾选了bAutoActivate ,则会执行激活Actor 。所以如果你想在Actor的 BeginPlay 之前做一些操作的话,最好的时机就是 PostInitializeComponents 方法内

接下来执行的代码是 Listen(URL), 启动 Server 的网络监听; 再就是调用所有Actors 的 BeginPlay

至此 Server 分析完毕,它目前具备了一个游乐场所需要的一切设备,梦幻的场景( UWorld,ULevel ), 有趣的游戏规则(UGameMode), 游乐场看门老头( Listen(URL) ) ,满心欢喜的在等待着 Client 加入这场游戏盛宴

Client

CodeSnippet.4 内的 GEngine->Init(this) 方法( CodeSnippet.5 ), 在Client端它会生成 GameViewportClient(可以想象成屏幕本身:它本质上就是作为渲染、声音以及输入系统的高级接口,也是用户与引擎之间的交互接口) 和 LocalPlayer(可以想象成坐在屏幕前的用户)

而CodeSnippet.4 内的 GEngine->Start()方法在 Client 它会调用到 UEngine::Browse() , 代码如下:

//UnrealEngine.cpp
EBrowseReturnVal::Type UEngine::Browse( FWorldContext& WorldContext, FURL URL, FString& Error )
{
    if( URL.IsLocalInternal() )
    {
       //Server: Create GameMode,GameSession,GameState,GameNetWorkManager
       return LoadMap( WorldContext, URL, NULL, Error ) ? EBrowseReturnVal::Success : EBrowseReturnVal::Failure;
    }
   
    if( URL.IsInternal() && GIsClient )
    {
       //Client, Create UPendingNetGame,UNetDriver,send "MIT_Hello" to Server
       WorldContext.PendingNetGame = NewObject<UPendingNetGame>();
       WorldContext.PendingNetGame->Initialize(URL);
       WorldContext.PendingNetGame->InitNetDriver();
       return EBrowseReturnVal::Pending;
    }
 }
//CodeSnippet.8

CodeSnippet.8 的主要功能是创建 UpendingNetGame 并初始化执行登录流程,流程大概如下:

UE4 登录流程
登录ds服务器流程

Create Pawn In Server

那么Server 和 Client , 分别是什么时候开始生成玩家的呢?

Server 是在收到 NMT_Join 消息, UWorld::SpawnPlayActor 生成 PlayerController, 代码如下:

APlayerController* UWorld::SpawnPlayActor(UPlayer* NewPlayer, ENetRole RemoteRole, const FURL& InURL, const FUniqueNetIdRepl& UniqueId, FString& Error, uint8 InNetPlayerIndex)
{
    AGameModeBase* const GameMode = GetAuthGameMode()

    //Give the GameMode a chance to accept the login
    APlayerController* const NewPlayerController = GameMode->Login(NewPlayer, RemoteRole, *InURL.Portal, Options, UniqueId, Error);

    //Possess the newly-spawned player
    NewPlayerController->NetPlayerIndex = InNetPlayerIndex;
    NewPlayerController->SetRole(ROLE_Authority);
    NewPlayerController->SetReplicates(RemoteRole != ROLE_None);
    NewPlayerController->SetPlayer(NewPlayer); //NewPlayer type is UNetConnection

    //Spawn Pawn
    GameMode->PostLogin(NewPlayerController);
    return NewPlayerController;
}
//CodeSnippet.9

在 NewPlayerController 的构造方法执行之后 PostActorConstruction方法会被调用,它内部会依次调用 PreInitializeComponents,InitializeComponents,PostInitializeComponents。 与 PlayerController 对应的 PlayerState 则会在 PostInitializeComponents 方法内生成并执行初始化

//PlayerController.cpp
void APlayerController::PostInitializeComponents()
{
    if ( !IsPendingKill() && (GetNetMode() != NM_Client) )
    {
       // create a new player replication info
       FActorSpawnParameters SpawnInfo;
       SpawnInfo.Owner = this;
       SpawnInfo.Instigator = GetInstigator();
       SpawnInfo.ObjectFlags |= RF_Transient; 

       const AGameModeBase* GameMode = GetWorld()->GetAuthGameMode();
       PlayerState = World->SpawnActor<APlayerState>(GameMode->PlayerStateClass, SpawnInfo);
    } 
} 
//CodeSnippet.10

而PlayerState 创建以后,也会执行 PostInitializeComponents 方法,将自己放入 GameStateBase 中; 这样 GameStateBase 就能获取到所有加入到游戏的玩家信息(通过 PlayerArray), 通过玩家信息又能获取到对应的 PlayerController, 通过 PlayerController, 就能获取到 UPlayer (type is UNetConnection)

生成PlayerControllery以后,接下来调用GameMode->PostLogin, 这里就会生成最终我们控制的Pawn

Create Pawn In Client

Client 在收到 NMT_Welcome 消息后开始加载地图,CodeSnippet.6内 SpawnPlayActor() 被调用,之后的流程和 Server 一致

小结

GamePlay的初始化流程分为两个大的阶段:

  • LoadMap 之前: 生成 GameEngine, GameInstance, WorldContext, GameViewportClient(Client Only) , LocalPlayer(Client Only)
  • LoadMap 之后: URL.umap资源序列化以后的对象(UWorld, ULevel, AActor, UActorComponent…), GameModeBase(Server Only), GameSession (Server Only) , GameStateBase, GameNetworkManager (Server Only) , PlayerController, PlayerState, Pawn

LoadMap 之前生成的对象只有一个, GameInstance 的数量在 PIE 模式下有点特殊, 会根据 Number of Players 生成多个(Play Offline/Play As Listen Server: n , Play As Client:n+1)

至此,GamePlay启动流程基本结束。如果对文中一些类的具体用途以及为什么如此设计,则可以翻阅大钊老师的专栏《InsideUE5》,里面详细讲解了GamePlay框架中最重要的几个类的设计原理以及具体用途

本文的内容主要来自youtube上一位大佬的视频讲解(有好心人搬到了B站), 最好的学习方法就是照着视频的内容,一步步看源码然后记录下来,记载成文加深记忆和理解

youtube:The Unreal Engine Game Framework: From int main() to BeginPlay
bilibili:UE4游戏框架:从int main()到BeginPlay

[原创]C++基础系列—const和constexpr

constant 意思是”常量”, 而 const 和 constexpr 这两个关键字和 constant 有一定的关系。常量的广义定义是:不变化的量(比如:重力加速度,圆周率,光速等)。说这两个关键字之前,我们还得提一下同样可以定义常量的关键字:#define

#define

你可能见过如下代码:

# include <stdio.h>
# define PI 3.14
int main(void)
{
    float s;
    s = PI * 5 * 5;
    printf("s = %.6f\n", s);
    return 0;
}

此时,PI 的作用就是一个常量。这样写的好处是凡是代码中用到圆周率计算的地方,直接用 PI 替换,要扩展圆周率的精度,只要修改 PI 的值就行。#define定义常量也有一些问题:

  • #define 是宏,它仅仅是做字符匹配替换,没有语法检测;而且替换以后通常是纯右值,无法当作变量使用
  • 无作用域,容易字符冲突(比如:Max,Min)

当然,#define 也有以下一些优势:

  • 防止头文件重复包含(#ifndef XXX #define XXX #endif)
  • 条件编译(ifdef XXX… #else …#endif)

const

const关键字,其实有两种语义

  • 表示常量 const float PI = 3.14f
  • 修饰的对象不可改变

const也可以修饰函数,表示当前函数不允许修改被调用对象的值(注意: const 函数内不可调用非 const 函数)

const还可以修饰指针,它所在的位置不同,语义也有不同(*左定值,*右定向) :

  • 修饰指针指向的内容,则内容为不可变量 const int* p = &a; *p =3; //error
  • 修饰指针,则指针为不可变量 int* const p = &a; p = &b; //error

在C++11引入constexpr以后,官方不推荐用const表示常量,因此也可”片面”的认为,const关键字其实就是C#的关键字:readonly

constexpr

这是在C++11引入的关键字,在C++20以后,限制就越来越少,甚至可以修饰 lamda 表达式。它和const最主要的区别是:constexpr所修饰的常量必须是编译期常量(注意:constexpr 所修饰的函数,返回值则不一定要求是编译期常量),如下代码:

const int len = 5; //不推荐
constexpr int len = 5; //推荐
int a[len];

len是一个编译期就需要知道的常量,const 并不一定编译期就可获取它的值,因此严谨的写法应该是下面那种。至于constexpr的其他用处,那就得在元编程中大显身手了,这不在本篇基础文章讲解范围内

[原创]C++基础系列—右值引用

既然有了左值引用,看似一切都很美好了。为什么还需要右值引用这个东西呢?引入左值引用,解决了运算符重载的问题;C++11引入右值引用,则是为了追求极致性能

左值( lvalues ) 右值( rvalues )

为了了解右值引用,我们再次明确一下,左值和右值的概念。 Lvalue Persist;Rvalue Are Ephemeral《C++ Primier》,更简单的概念就是:左值可获取存储地址;右值不可获取地址,只能获取值本身。详细判断左值右值的地址:Value categories

左值引用

左值引用,只能绑定左值,注意下面例子最后一个,1是右值没错,但其实它可以分解为注释后的两句代码,因此也就能理解,它并没有超脱左值引用的定义

int foo = 1;        //foo is lvalue, 1 is rvalue
int* bar = foo;     //foo is lvalue , bar is lvalue
int* baz = 1;       //error: 1 is rvalue
const int* qux = 1; // const int temp = 1, const int* qux = temp;

右值引用

右值引用,只能绑定右值(也就是绑定的是 生命周期在下一行就会结束的临时值)

int foo = 1;
int&& baz = foo;      //error: foo is lvalue
int&& qux = 1;        //1 is rvalue
int&& quux = foo * 1; //(foo * 1) is rvalue
int&& quuz = foo++;   //foo++ return a rvalue

右值引用它能绑定临时值,本来在下一行之前就会”消失”的数据,我们能再次利用,最重要的一点:可以任意用它而不用担心会影响其它地方;可能还是一头雾水,我们通过一个例子来展示一下:

#include <iostream>
#include <vector>
using namespace std;

class Foo
{
public:

    Foo() { data = nullptr; }

    Foo(const char* str)
    {
        initdata(str,"构造");
    }

    Foo(const Foo& foo)
    {
        initdata(foo.data, "拷贝构造");
    }

    Foo& operator=(const Foo& foo)
    {
        initdata(foo.data, "拷贝赋值");
        return *this;
    }

    ~Foo()
    {
        if (data != nullptr) {  free(data); }
    }

private:

    void initdata(const char* str, const char*  des)
    {
        data = new char[strlen(str) + 1];
        memcpy(this->data, str, strlen(str));
        data[strlen(str)] = '\0';
        cout << des << data << endl;
    }

    char* data;
};

int main()
{
    Foo foo = Foo("hello");
    Foo bar = foo;

    vector<Foo> vec;
    vec.push_back(Foo("world"));
}

打印结果:
构造hello
拷贝赋值hello
构造world
拷贝构造world

每一次都有 new char[] 操作,相当于有4次内存申请操作,我们在Foo中插入以下代码;再输出:

Foo(Foo&& str)
{
    cout << "移动构造" << str.data << endl;
    data = str.data;
    str.data = nullptr;
}

Foo& operator=(Foo&& str)
{
    cout << "移动拷贝赋值" << str.data << endl;
    if (this != &str) {
        this->data = str.data;
        str.data = nullptr;
    }
    return *this;
}

打印结果:
构造hello
移动拷贝赋值hello
构造world
移动构造world

虽然也有4条输出,但实际 new char[] 只执行了2次。移动拷贝赋值 和 移动构造 里面没有 new char[] 操作,只是指针重定向。你可能会有疑问:你内部实现方式都不一样,那肯定不需要 new char[] 啊 ?很高兴,你已经Get到点了,那为什么可以直接重定向而不需要 new char[] ?

因为它的参数是右值引用。请翻到上文加粗部分,右值引用它绑定的是临时值,它不会被其它任何地方的代码引用,因此可以放心将它的 data 指向自己而不用担心会牵扯其它地方。

此刻你是不是灵机一动?想到了右值引用的核心了:就是告诉编译器,这个”值”已经没人疼没人爱,你带走随便操作吧。那是不是我可以把我认为后面我不会再用到的值,直接转成右值引用呢?是的,可以。上个示例,main函数改成如下:

int main()
{
    Foo foo = Foo("hello");             //第一行
    Foo bar = static_cast<Foo&&>(foo);  //第二行
    return 0;                           //第三行
}

在第三行断点,你可已发现,foo内的 data 值已经变为NULL,hello 从foo “转移”到了 bar 。这就引出了经常讨论的 move semantics (移动语义)

move semantics(移动语义)

其实移动语义,就是上文main函数的第二行,更改如下:

int main()
{
    Foo foo = Foo("hello");  //第一行
    Foo bar = std::move(foo);//第二行
    return 0;                //第三行
}

std::move(foo) 等于 static_cast<foo&&>(foo) ,就是将左值强转为右值引用。就是告诉编译器,这个左值绑定的值,左值再也不对它负责。因此,第二行以后就不应该再使用 foo 做一些操作,可能导致崩溃

那既然这样,为什么还要std::move() 语义?直接 static_cast<T&&>() 不就好了吗?两点原因:

  • std::move() 语法更简洁,方便代码理解
  • std::move() 不需要显示书写类型(T&&)

注意:右值引用本身是左值,这是啥意思呢?代码如下:

void func(Foo&& foo)
{
    //foo do something
}

int main()
{
    Foo foo =  Foo("hello");
    Foo bar = std::move(foo);
    func(bar); //error     //第四行
    func(Foo("hello"));    //第五行
    func(std::move(foo));  //第六行
    func(std::move(bar));  //第七行
    return 0;
}

bar 变量本身是左值,可以取地址 (虽然他绑定的是一个右值引用) ,因此第四行的代码编译报错。move 除了使用在移动构造,还可以用于智能指针。而且 std::swap就是通过move实现的,伪代码如下:

template<typename T>
void swap(T& a,T& b) noexcept
{
    T temp = std::move(a);
    a = std::move(b);
    b = std::move(temp);
}

至此,我们已经知晓左值引用,右值引用,而且语义明确,不会有任何歧义。但是在C++中,有个核弹——模板,它让情况变复杂了

万能引用

一个方法的形参,类型要么是左值引用,要么是右值引用

void func(Foo& foo) {}
void func(Foo&& foo){}

有没有同时接受左值引用和右值引用的方法呢?有的,万能的模板可以实现你的愿望

template<typename T>
void func(T&& param) 
{
    cout << param << endl;
}

int main() 
{
    int foo= 1;
    func(foo); //foo的类型:int&;(因为func只接受引用)
    func(1); //数值1的类型:int&&
    return 0;
}

是不是很神奇,将 T 替换成我们传入的类型,那么函数实际参数类型如下:

void func(int& && param) 
{
    cout << param << endl;
}
void func(int&& && param) 
{
    cout << param << endl;
}

“& &&” 和 “&& &&” 是不存在的类型,如下定义全都会报错:

(int&)&& foo;
int& &&  bar;
int&&&   baz;

那 T&& 为啥可以呢?因为C++ 11 定义了折叠引用规则

引用折叠(Universal Collapse)

func(foo) 传入的 T 推导类型:int&,因此实际类型是:int& &&,这是个不存在的类型,因此 C++ 标准强行规定了一个规则:

如果任一引用为左值引用,则结果为左值引用;否则结果为右值引用。 有点不好理解,我换个说法,左值引用是 0 ,右值引用时 1,折叠规则就是:左值引用 and 右值引用(这里的and是算数操作符 与 ),因此:

int& && = 0 and 1 = int&
int&& & = 1 and 0 = int&
int& & = 0 and 0 = int&
int&& && = 1 and 1= int&&

这也是为什么万能引用可以既接受左值引用又可以接受右值引用的原因。但是请注意折叠引用仅适用于编译器自己推出来的类型,手动定义的类型编译器不会遵循折叠引用,因此上文的定义 (int&)&& foo; int& && bar; int&&& baz; 都会报错

既然万能引用技能接受左值引用,又能接受右值引用。那有个问题,如果我func里面要调用其它方法,将 param 当作参数传给它,那么它的类型是不是就不对了呢?

完美转发(Perfect Forwarding)

template<typename T>
void bar(T& param) 
{
    cout << "param is lvalue" << endl;
}

template<typename T>
void bar(T&& param)
{
    cout << "param is rvalue" << endl;
}

template<typename T>
void foo(T&& param)
{
   bar(param);
}

int main() 
{
    int baz = 1;
    foo(baz);
    foo(1);
    return 0;
}
//Consloe:
//param is lvalue
//param is lvalue

结果都是左值,为什么呢?因为 param 相当于一个变量,它绑定的是传进来的T&&类型的值(无论这个值折叠以后是什么类型,变量总是左值)。这显然和我们的期望相违背,那怎么办呢?再强转一次呗

template<typename T>
void foo(T&& param)
{
    bar(static_cast<T&&>(param)); //替换bar(param);
}

将 param 强转回 T&& 类型,再传给 bar 函数,编译器就能准确找到对应的函数。同样,static_cast<T&&>(),也有对应的简洁写法,std::forward<T>()

总结

C++ 11引入右值引用,主要目的是为了提高性能(弥补 拷贝构造,拷贝赋值等操作必须进行内存申请等很消耗性能的操作),最大限度的利用了临时值。而移动语义,完美转发 的本质其实就是类型转换 static_cast<T&&>() ,并不神秘

[原创]C++基础系列—左值引用

有没有想过一个问题,为什么C++ 有了指针( Pointer),仍然引进 “引用(Reference)” 这个概念?C++之父的解释:

C++ inherited pointers from C, so I couldn’t remove them without causing serious compatibility problems. References are useful for several things, but the direct reason I introduced them in C++ was to support operator overloading (简译: C++ 为了兼容 C,所以不可能会移除指针;引用在很多地方有用处,但主要是为了解决运算符重载 )

operator overloading (运算符重载)

运算符重载为什么必须要用到引用呢?我们看看下面一个实例,你就能理解为什么需要引用

#include <iostream>
using namespace std;

class Box
{
public:
    int length;
    Box(int len){ length = len; }

    //值方式重载
    Box operator+=(const Box b)
    {
        Box box(0);
        box.length = this->length + b.length;
        return box;
    }

    //引用方式重载
    Box& operator+=(const Box& b)
    {
        this->length = this->length + b.length;
        return *this;
    }

    //指针方式重载
    Box* operator+=(const Box* b)
    {
        this->length = this->length + b->length;
        return this;
    }
};

Box GetBox() { return Box(1); }

int main()
{
    Box box(1);
    //值方式重载示例
    (box += box) += box; //result:1
    //引用方式重载示例
    (box += box) += box;//result:4
    //指针方式重载示例
    *(box += &box) += &box;//result:4
    //只有引用方式的重载才能正确编译
    box += GetBox();
    cout << "box.Length = " << box.length << endl;
    return 0;
}

值方式重载有两个问题:

  • 构造函数被调用多次;浪费性能
  • 功能未实现;(box += box) += box 结果不符合预期

指针方式重载也有以下两个问题:

  • 写法不够简洁,晦涩难懂
  • box += GetBox() 编译不过(不能用 “右值” 当实参)

函数参数

C++ 之父说还说,引用在有些地方也很有用。除了重载运算符,作为函数参数也很有用,示例如下:

void Func(Foo foo);//有拷贝构造,不推荐
void FuncPtr(Foo* foo);//无拷贝构造,但是foo使用之前需判空
void FuncRef(const Foo& foo);//无拷贝构造,官方推荐(可传入右值实参)

你是不是以为就这了?其实它还有个隐藏的用处,请看以下代码:

void foo(const int& value)
{
    int temp = value + 1;
}

void bar(const int* value)
{
    int temp = *value + 1;
}

int main()
{
    int value = 1;
    int& ref = value;
    int* ptr = &value;

    foo(2);
    foo(value);
    foo(ref);

    bar(2);      //error
    bar(value);  //error
    bar(ptr);    //error
    return 0;
}

函数 foo(const int& value) 可以接受三种实参, 这一点在模板里面很有用 ;而 bar(const int* value) 只能接受指针类型实参

函数返回值

还有没有其它用处呢?我觉得写单例的时候会用到。以下是 C++ 单例的 “标准” 写法:

class Singleton 
{
 private:
    Singleton (void)

 public:
    static Singleton& getInstance()
    {
        static CSingleton m_pInstance;
        return m_pInstance;
    }
};

为什么引用的单例比指针的更好?因为安全,返回的是引用不可被更改,而且不会为空

总结

综上,引用的作用有以下地方:

  • operator overloading;不仅是为了简化语法,还是为了实现功能
  • 作为函数形参
  • 作为函数返回值

当然,我们这里讨论的都是普通引用,也就是左值引用;C++ 11以后引入了右值引用,初看很迷惑,实际也是为了解决问题,下一篇我们再来谈谈

[原创]UE— Garbage Collection

这是一篇长文,涵盖了UE4垃圾回收的大部分知识点。如若有错误之处,望留言纠正(亦可邮件探讨:lingzelove2008@163.com),感激不尽

垃圾回收起源

何为垃圾回收 (Garbage Collection) ?它是一种自动管理资产 (Assets) 生命周期的运行时机制

UE 采用的编程语言是 C++,这也是很多 Unity 程序员转行到 UE 的第一个重量级拦路虎。有好心的 UE 前辈会告诉你 : “UE 的 C++ 是魔改版,用起来跟 C# 差不多”。这句话有一定的道理,让前辈们有这种错觉最重要的一点就是,UE 引擎实现了垃圾回收(C++标准未实现 )

C++的内存管理,new 必须对应 delete ; 但是游戏项目非常庞大,指针满天飞,你压根不知道应该在哪儿 delete 。隔壁 Unity 只需要 new 系统会自动 delete,这让 UE 引擎专家甚是羡慕,就强撸了一个垃圾回收系统来自动 delete,让 UE 的开发难度骤降

垃圾回收的功能

如果让你来实现这个垃圾回收系统,你觉得垃圾回收系统应该具备哪些功能呢?

  • 实现自定义的 NewObject 方法( 无需调用对应的DeleteObject )
  • 自动回收没有被引用的垃圾对象
  • 不影响其他系统的正常功能

垃圾回收伪实现

垃圾回收的算法有很多, 标记-清除算法 、 复制算法 、标记-整理算法 、分代收集算法等。我们就用最”简单”的标记清除算法来实现。标记-清除算法,看名字就知道有两个阶段,标记和清除:

  • 标记:遍历所有对象,根据某种规则,标记其是否需要清除
  • 清除:遍历所有对象,清除标记了的对象,回收内存

因此可知,要实现标记清除垃圾回收,在标记阶段我们需要做到以下两点:

  • 能拿到所有对象
  • 确定对象清除的规则

在自定义的 NewObject 方法内,把生成的对象指针放入全局数组 GUObjectArray ,这样我就能拿到所有对象了

想象一个画面:空场景内站着一个英雄。这个情况下,垃圾回收系统是不是应该围绕着英雄来判断?英雄用到的对象就保留,没用到的对象就清除。此时这个英雄就是 “根对象”。因此,标记清除的规则就是,根对象用到的对象保留,其他对象清除。那根对象怎么确定?就得我们”手动”标记(AddToRoot)

以下就是垃圾回收的伪实现:

  • 启动垃圾回收,加锁( 保持所有对象的引用关系不变 )
  • 设置所有对象为”不可达”标记(根对象、特殊对象 除外)
  • 遍历根对象列表,根对象引用到的对象去除”不可达”标记
  • 收集所有仍然标记为”不可达”的对象,全部删除

垃圾回收 UE4 实现

UE4的垃圾回收过程,正是照着上文的伪实现一步步执行的,不过细节更丰富,效率也更高

GC 启动

手动调用:UWorld::ForceGarbageCollection( bool bFullPurge),它会在World.tick 的下一帧强行进行垃圾回收

自动调用:系统会根据默认的设置(可重新配置)一定的间隔时间或者条件下,自动调用垃圾回收

GC 锁

void CollectGarbage(EObjectFlags KeepFlags, bool bPerformFullPurge)
{
    AcquireGCLock(); //获取GC锁
    CollectGarbageInternal(KeepFlags, bPerformFullPurge); //垃圾回收
    ReleaseGCLock(); //释放GC锁
}

GC锁的主要用处就是为了暂停其他线程以免UObject对象的引用关系在GC过程中发生变化。主要步骤:

  • 发送信号,表示我想获取GC锁,GCWantsToRunCounter 自增(原子操作)
  • GC 线程 Sleep,查看 AsyncCounter 是否等于 0 判断其他线程是否有阻塞GC的操作还在执行,不等于 0 就继续等待
  • AsyncCounter = 0,通过另一个变量 GCCounter 递增(原子操作),来标识正在执行GC,其他所有线程将被阻塞
  • 执行内存屏障
  • 将 GCWantsToRunCounter 设为 0,开始真正的 GC 操作
  • GC 操作完毕, GCCounter 自减释放 GC 锁

内存屏障的主要意思就是,在这个屏障之前的所有读和写的操作,一定会在这个屏障后面的读和写的操作之前执行。为了防范多线程读写操作时序问题导致的逻辑 bug,详细内容自行 Google

获取所有对象

上文伪实现,NewObject的时候把生成的对象的指针放入一个全局数组,实际上UE4确实也是这么做的。UObject对象继承自UObjectBase,它的构造方法如下(代码有删减改动):

//UObjectBase.cpp

//NewUObject方法调用后,UObject对象初始化
UObjectBase::UObjectBase(UClass* InClass, EObjectFlags InFlags, EInternalObjectFlags InInternalFlags, UObject *InOuter, FName InName)
{
   AddObject(InName, InInternalFlags); 
}

void UObjectBase::AddObject(FName InName, EInternalObjectFlags InSetInternalFlags)
{ 
   //将对象加入GUObjectArray,并且为Object分配InternalIndex(对象的索引位置)
   GUObjectArray.AllocateUObjectIndex(Object);
}

GUObjectArray 虽然命名是 Array 结尾,实际上它是个容器体。虽然它里面一堆花里胡哨的东西,实际上只要理解它是为了多线程的时候分块扫描其存储的内容而设计就行。UObject对象不接存入容器的,而是被组装成了 FUObjectItem 结构:

//UObjectArray.h

//对象存储的结构体,GC操作的就是这个对象
struct FUObjectItem
{
   class UObjectBase* Object; //对象
   int32 Flags;               //EInternalObjectFlags标识
   int32 ClusterRootIndex;    //当前所属簇索引
   int32 SerialNumber;        //对象唯一序列码(WeakObjectPtr实现用到它)
}

这个结构体对于理解GC很重要。成员 Object 是 NewObject 生成的对象的指针,EInternalObjectFlags 是啥呢?就是用来做标记的枚举类型。结构如下:

//ObjectMacros.h

enum class EInternalObjectFlags : int32
{
   None = 0,
   ReachableInCluster = 1 << 23, ///< 簇中可达
   ClusterRoot = 1 << 24, //cluster root 不会被GC回收,簇根节点
   Native = 1 << 25, // UClass
   Async = 1 << 26, //异步对象
   AsyncLoading = 1 << 27, //异步加载中
   Unreachable = 1 << 28, // 不可达对象,会被GC删除
   PendingKill = 1 << 29, // 等待析构,会被GC删除
   RootSet = 1 << 30, // 根节点
   
   GarbageCollectionKeepFlags = Native | Async | AsyncLoading, //拥有这三种标记之一的对象在GC检测时会被跳过
};

标记不可达

以下代码就是获取所有对象,并根据标签 标记部分对象为不可达;可达对象都放入 ObjectsToSerialize 数组内 ( 代码有删减,部分变量名用auto替换,是为了减少长度)

//GarbageCollection.cpp

void PerformReachabilityAnalysis(EObjectFlags KeepFlags, bool bForceSingleThreaded, bool bWithClusters)
   {
      //从系统提供的数组池中获取数组(为了支持多线程)
      auto ArrayStruct = FGCArrayPool::Get().GetArrayStructFromPool();
      auto ObjectsToSerialize = ArrayStruct->ObjectsToSerialize;

      // 继承了FGCObject的非Uobject对象,放入ObjectsToSerialize
      ObjectsToSerialize.Add(FGCObject::GGCObjectReferencer);

      //将对象标记为不可达,并且将根节点以及不可删除对象放入ObjectsToSerialize
      MarkObjectsAsUnreachable(ObjectsToSerialize, KeepFlags);
      
      //分析ObjectsToSerialize数组内的对象,它能达到的对象,去掉不可达标签
      PerformReachabilityAnalysisOnObjectsInternal(ArrayStruct)
   }

代码都有注释,很好理解;ObjectsToSerialize.Add(FGCObject::GGCObjectReferencer); 这一行,很关键,这也是为什么你看很多文章,都说非UObject对象,继承 FGCObject 后,也可以将它引用的对象加入垃圾回收

class FMyStruct: public FGCObject
{
    UObject* NoGCObj;

    void AddReferencedObjects(FReferenceCollector& Collector) override
    {
	Collector.AddReferencedObject(NoGCObj);
    }
}

这部分的代码分析,可见我的上一篇文章:[原创]UE —UObject类智能指针

引用关系分析

MarkObjectsAsUnreachable() 方法消耗不大,因为是多线程操作,且这些 FUObjectItem 结构体对象是内存块连续的数据,遍历不会有缓存 miss。PerformReachabilityAnalysisOnObjectsInternal(ArrayStruct) 才是最复杂,最耗时的操作

UE 是怎么获取一个对象引用了哪些其他对象呢?

UE 中每个 UObject 对象都有一个与之对应的 UClass , 这个UClass保存了对应UObject 的所有反射系统相关信息,描述了各个 Property 之间的内存布局和关系;通过UObject对象的实例化地址就可以将相应的属性遍历出来,进行读写(注意:UClass描述的属性是我们手动加了 UPROPERTY() 标签的)

Obj 对象所引用的其他 UObject 对象,伪代码如下:

//遍历Obj对象的所有属性,如果属性的类型是UObjectle类型,就取消不可达标签
for(TFieldIterator<FProperty> PropertyIter(Obj->GetClass()); PropertyIter; ++PropertyIter)
{
    const FProperty* PropertyIns = *PropertyIter;
    //该属性是否是UObject,是否是Tarray<UObject>,是否是TMap<key,UObject>等
    //然后依次取消 "不可达"标记
    //然后递归遍历该属性对象所引用的对象......
}

这么实现,确实可以 ;但是 UE 并没有这么做,为什么呢?因为属性内其实大部分都是非 UObject类型 ,全部遍历效率太低。因此在生成 UObject对应 UClass 的时候,就构造了一个新的概念,将所引用的其他对象用一个很巧妙的整数,存入 ReferenceTokenStream 变量的 Tokens 数组内

ReferenceTokenStream

断点一个UObject对象,查看其ReferenceTokenStream,内容如下:

这个里面包含当前对象 Obj 引用到的所有标记了 UPROPERTY() 的属性(包括它的父类),那这个数字到底是啥意思呢 ?其实它是三个数字的合并。前8位, 引用对象的嵌套深度 ;中间5位,引用对象的类型( EGCRefenceType );最后则是当前引用的对象的变量在 Obj 对象内的内存偏移值(根据这个偏移值,可获取这个引用的对象)

struct FGCReferenceInfo
{
    union
    {
        struct
        {
            uint32 ReturnCount  : 8;
            uint32 Type         : 5;
            uint32 Offset       : 19;
        };
        //ReturnCount + Type  + Offset
        //00000000    + 00000 + 0000000000000000000
        uint32 Value; 
    };
};

根据 Tokens 内的值,我们可以获取它引用的属性的类型。比如: GCRT_ArrayObject 、 GCRT_ArrayStruct、 GCRT_Object等,根据它们的类型做不同的操作。PerformReachabilityAnalysisOnObjectsInternal 代码内最后调用的分析代码是 ProcessObjectArray (以下代码有删减):

void ProcessObjectArray(FGCArrayStruct& InObjectsToSerializeStruct, const FGraphEventRef& MyCompletionGraphEvent)
{
    while (CurrentIndex < ObjectsToSerialize.Num())
    {
        CurrentObject = ObjectsToSerialize[CurrentIndex++];
        // 获取当前对象的ReferenceTokenStream
        auto* TokenStream = &CurrentObject->GetClass()->ReferenceTokenStream;
        uint32 ReferenceTokenStreamIndex = 0;
        //当前对象的起始地址
        uint8* StackEntryData = (uint8*)CurrentObject;
        while (true)
        {
			//注意:源码这里不是直接++,但是为了方便理解,这里直接用
	    ReferenceTokenStreamIndex++;
	    FGCReferenceInfo ReferenceInfo = TokenStream->AccessReferenceInfo(ReferenceTokenStreamIndex);
            switch(ReferenceInfo.Type)
            {
                case GCRT_Object:
                case GCRT_Class:
                {
                    // 引用对象的地址: 起始地址 + Offset
                    UObject**   ObjectPtr = (UObject**)(StackEntryData + ReferenceInfo.Offset);
                    UObject*&   Object = *ObjectPtr;
             ReferenceProcessor.HandleTokenStreamObjectReference(Object...);
                }
                break;
                case GCRT_ArrayObject:
                {
                    TArray<UObject*>& ObjectArray = *((TArray<UObject*>*)(StackEntryData + ReferenceInfo.Offset));
                    for (int32 ObjectIndex = 0, ObjectNum = ObjectArray.Num(); ObjectIndex < ObjectNum; ++ObjectIndex)
                    {
          ReferenceProcessor.HandleTokenStreamObjectReference(Object...);
                    }
                }
                break;
            }
        }
    }
}

HandleTokenStreamObjectReference 会调用 HandleObjectReference,它会去除它的”不可达”标记,并将它加入NewObjectsToSerialize,开辟新的 task 线程去处理,而不是在当前线程递归

清理

GC 的清理过程,在函数 IncrementalPurgeGarbage 内,它的两个参数可以让我们选择是增量清理还是全量清理,清理的过程分为两步:收集 和 清理

收集不可达对象:GatherUnreachableObjects,多线程操作, 遍历GUObjectArray 将所有标记为”不可达”的 FUObjectItem 加入 GUnreachableObjects 数组

清理看起来实现应该很简单, 直接遍历 GUnreachableObjects 数组,然后delete就行,实际很复杂,具体步骤如下:

  • 获取 GC 锁(因为 BeginDestroy 到 FinishDestroy 之间可能会有异步操作)
  • 执行 UnhashUnreachableObjects 方法,遍历数组GUnreachableObjects,调用对象的 BeginDestroy 方法,可重载这个方法进行自定义操作
  • 执行 IncrementalDestroyGarbage 方法,遍历数组 GUnreachableObjects ,调用对象的 FinishDestroy 方法,注意:此时可能 BeginDestroy 内的异步方法还没执行完毕,因此 FinishDestroy 方法会执行失败,先把对象存储到 GGCObjectsPendingDestruction 中,之后再处理
  • 遍历数组 GGCObjectsPendingDestruction, 尝试调用对象的 FinishDestroy 方法
  • TickDestroyGameThreadObjects 执行真正的删除对象,遍历 GUnreachableObjects 内的所有对象;执行之后的操作,代码如下:
// TickDestroyGameThreadObjects 对垃圾对象最后的处理 
FUObjectItem* ObjectItem = GUnreachableObjects[ObjCurrentPurgeObjectIndexOnGameThread];       
if (ObjectItem)
{
   //将GUObject内的对象设置为nullptr
   GUnreachableObjects[ObjCurrentPurgeObjectIndexOnGameThread] = nullptr;
   UObject* Object = (UObject*)ObjectItem->Object;
   //调用析构函数
   Object->~UObject();
   //释放内存
   GUObjectAllocator.FreeUObject(Object);
}

至此,本文告一段落。当然,关于垃圾回收还有很多未解之谜。比如:神秘的UClass 是怎么生成的 ?它又是如何收集 UObject 的属性(标记了 UPROPERTY ),方法(标记了 UFUNCTION) ?ReferenceTokenStream是如何生成的 ? Cluster (簇) 是什么 ,它对GC有何影响?这些巨坑,等以后再慢慢填

[原创]UE—GAS系列:1. 基础概念

UE的技能系统 ( Gameplay Ability System 简称:GAS ) ,官方是以插件的形式嵌入在UE引擎中;你完全可以在自己的项目中用使用自己团队的”祖传”技能系统,但我还是推荐大型项目优先使用 GAS 。它的优点是:度高灵活、可复用性强;缺点是:过于庞大、不易上手

如果你是第一次接触 GAS,可能被一些专有名词给绕晕 :GameplayAbility(GA)、 AbilitySystemComponent(ASC) 、GameplayEffect (GE)、AttributeSet(AS)、 GameplayCue (GC); 那么,我就通俗易懂的解释一下这些概念

GameplayAbility(GA)

武当有一门神技《梯云纵》,施展以后就能一跃十丈,潇洒无边。这本秘籍就是GA ,我们来看看这本秘籍的内容:梯云纵(第一重)

神技心法:

  • 气沉丹田
  • 聚气成力

神技说明:

  • 每天只能施展一次
  • 每次施展消耗一点真气

神技威力:

  • 一跃而起,离地一丈
  • 形成雷鸣,方圆一里

AbilitySystemComponent(ASC)

想要习得上乘武功,就必须有习武的神经中枢(俗称:天赋),没有天赋只有秘籍那就是个王语嫣;而这个神经中枢就是ASC

因此想要施展技能,Character 必须挂上ASC 组件,神经中枢是通过 GiveAbility 融汇贯通了梯云纵的神技心法

ASC->GiveAbility(const FGameplayAbilitySpec& Spec);
FGameplayAbilitySpec 主要参数如下:
UGameplayAbility* GA;  //梯云纵
int32 Level;           //第一重
UObject* SourceObject; //谁习得梯云纵(自己)

GameplayEffect (GE)

融汇贯通了梯云纵心法,那是不是就可以出门行侠仗义,闯荡江湖了呢?还是得从长计议,神技虽然威力无边,但也有很大的自身限制,因为神技第一重每天只能使用一次。用GAS它是怎么实现的呢?那就要用到 GE 了

GE 通俗一点理解就是 Buff,DeBuff ;施展完一次梯云纵以后,GA会告知 ASC,请在小本本上写一行标记: CoolDown.Skill.TiYunZong ,然后24小时后删掉这行标记。伪代码如下:

//GE相当于一份配置文件,包含ASC要记的标记,持续时间等要素
UGameplayEffect* CooldownGE = GetCooldownGameplayEffect();
if (CooldownGE)
{
    const auto SpecHandle = MakeOutGESpec(CooldownGE->GetClass(), 1);
    ApplyGameplayEffectSpecToOwner(ActivationInfo, SpecHandle);
}

施展梯云纵之前,ASC 首先会去小本本检查是否有 CoolDown.Skill.TiYunZong 这个标签。如果有那就表示距离上次使用未超过一天时间不得再次施展,这就是GAS 进行技能冷却的原理

AttributeSet(AS)

施展梯云纵之前,中枢神经ASC还会去检查你体内是否有足够的真气,而真气就是 AS,也就是GAS中常说的属性点,比如: 真气(Mana),血量(Health) 等。那么扣一点属性点,是怎么实现的呢?同样是使用 GE 告知 ASC,你扣除身上的Mana属性值 1 点,瞬间完成。伪代码如下:

UGameplayEffect* CostGE = CostEnergyGE;
const auto SpecHandle = MakeOutGESpec(CostGE->GetClass(), 1);
if (SpecHandle.Data)
{
    //代码有删改,意思就是设置GE内对应tag扣的值
    SpecHandle.Data->SetSetByCallerMagnitude("Data.Energy.Mana", 1);
    ApplyGameplayEffectSpecToOwner(ActivationInfo, SpecHandle);
}

这样,就完成了属性点扣除。同样,在梯云纵施展之前,出了要检查是否有 CoolDown.Skill.TiYunZong 这个标签,ASC 还会去检查属性点 Mana 是否足够你施展完以后扣除,不够就不能施展;强行施展后会抽走全身精气走火入魔

GameplayCue (GC)

施展梯云纵的时候,由于它威力巨大,会在方圆一里产生巨大的雷鸣,震撼八方 宵小之辈。那么GAS 是如何实现的呢?也是通过 GE 告知 ASC,丢出去一个一次性暗器 ( 也就是GC ),它会播放一个3D环绕雷声(声音范围:方圆一里)以及雷电特效,播放完以后自动销毁 。GE 配置了丢哪个暗器(Gameplay Cue Tag),以及何时销毁(Has Duration)

总结

注意: ASC 组件不一定非得挂在 Character 身上,你也可以挂在 PlayerState 身上,甚至游戏内任何想要使用技能的 Actor 身上。GE 是 GAS 的灵魂,也是重中之重,很多效果都是通过 GE 去驱动。GAS 还有一个核心 — FGameplayTag,但这是UE4引擎本身的标签系统;如果没有 FGameplayTag 配合,GAS 就显得平平无奇;FGameplayTag + GAS 组成强大的技能系统,可以实现任何上天入地的炫酷技能

至此,我们可以基本上明白GAS内的各个名词的功能。但实际上,这才是梯云纵的第一层,想要达到大圆满境界,我们仍需努力。以下是我觉得比较好的一些 GAS 视频链接,适合新手登堂入室

GAS新手链接:

[原创]UE —UObject类智能指针

上文谈到C++ 标准智能指针,是为了让程序员从指针的 new 和 delete 中抽身,更专注逻辑本身的开发。UE4 针对非 UObject 对象也实现了一套类似的智能指针,至于具体实现原理,可查看知乎文章:UE4的智能指针 TSharedPtr

强如 Java 和 C# 等更“智能化”的高端语言已经实现了垃圾回收机制,恐怖如斯;UE虽身披 C++ 却心向 C# ,自己也捣鼓了一个低端版本的垃圾回收,针对UObject类型对象的智能指针,主要有:TWeakObjectPtr,TStrongObjectPtr

WeakObjectPtr

我们知道继承了 UObject,并且被标记了 UPROERTY 的对象 Obj ,会被 GC 追踪(会被GC追踪的对象还有其它情况,本文暂不讨论);因此我们在使用 Obj 之前一般都会有如下代码安全开道:

if (IsValid(Obj))
{
    //Use Obj do something
}

GC 的删除过程是分帧处理,但是会提前标记 PendingKill,所以 IsValid(Obj) 依然返回 false,确保我们的代码不会出问题。但是,如果我们的代码是在 GC 清理完全以后再调用呢?上面这段代码就可能会导致 Crash ,为什么呢?

//Object.h
FORCEINLINE bool IsValid(const UObject *Test)
{
    return Test && !Test->IsPendingKill();
}

//UObjectBaseUtility.h
FORCEINLINE bool IsPendingKill() const
{
    return GUObjectArray.IndexToObject(InternalIndex)->IsPendingKill();
}

Obj 指向的 UObject 对象被初始化出来后,会被插入到数组 GUObjectArray,位置就是 InternalIndex 。GC后,GUObjectArray 数组内 InternalIndex 的位置被放入新的 UObject 对象,因此 IsValid ( Obj ) 返回的结果可能是 true;因此IsValid 判断 Obj 指针有效,而实际上 Obj 指针指向的内存地址里的内容解引用会失败,导致Crash

因此,我们希望有一种新型的智能指针Smart_Ptr用来包装 裸指针Obj,它必须满足以下两点要求:

  • Smart_Ptr 不能影响 Obj 的 GC
  • Obj 所指向的对象被 GC 删除以后,IsValid(Smart_Ptr) 会百分百返回 :false

UE很巧妙的撸了个 WeakObjectPtr ,实现原理很简单,它的核心就两个参数:

//WeakObjectPtr.h
int32	ObjectIndex;
int32	ObjectSerialNumber;

//WeakObjectPtr.cpp
ObjectIndex = GUObjectArray.ObjectToIndex((UObjectBase*)Object);
ObjectSerialNumber = GUObjectArray.AllocateSerialNumber(ObjectIndex);

ObjectIndex 就是当前WeakObjectPtr 指向的对象在数组GUObjectArray内的索引,而ObjectSerialNumber 则是该对象当初在创建的时候的唯一序列码 SerialNumber (赋值和自增都是原子操作,线程安全);因为它不直接引用 Obj 指针 ,因此不会影响 GC,然后 ObjectSerialNumber 会跟 GUObjectArray 内 ObjectIndex 位置对象的 SerialNumber 对比,不一样就表示根本不是同一个对象,使用如下:

//Assignment
TWeakObjectPtr<UMyObject> WeakObj = Obj;

//use
if(WeakObj .Get())
{
    UMyObject* Obj = Cast<UMyObject>(WeakObj);
    //Use Obj do something
}

因此,对于一些会隔帧执行的代码(定时器回调、 委托等),如果有用到 UObject 对象,强烈建议使用 WeakObjectPtr,能减少很多隐藏的Crash,让你的代码更乐百氏( Robust )

StrongObjectPtr

相对 WeakObjectPtr 来说, StrongObjectPtr 用的并不多。它提供的是一种绝对的把控,被他包装的 UObject 类型对象(不标记 UPROPERTY ),不会被 GC 自动清理,只有 StrongObjectPtr 析构以后,它包装的 UObject 才会被下一次 GC 清理

因此实现这种指针封装,需要满足以下两点:

  • 持有该对象,并且保证它不被 GC
  • 告诉GC,我用完了你可以清理它

我们查看 StrongObjectPtr 的实现,关键就一个变量:ReferenceCollector,类型是 FInternalReferenceCollector,继承自 FGCObject 。( ReferenceCollector 是 UniquePtr类型指针,因此它不能复制,只能转移所有权(OwnerShip))

为什么继承了 FGCObject 就可以被 GC 追踪 ?我们先看它的实现(注意:为了代码简短,去除了部分内容)

//GCObject.h
class COREUOBJECT_API FGCObject
{
   static UGCObjectReferencer* GGCObjectReferencer;
   static void StaticInit(void)
   {
      if (GGCObjectReferencer == NULL)
      {
         GGCObjectReferencer = NewObject<UGCObjectReferencer>();
         GGCObjectReferencer->AddToRoot();
      }
   }

   FGCObject(void)
   {
       StaticInit();
       GGCObjectReferencer->AddObject(this); 
   }

   virtual ~FGCObject(void)
   {
       GGCObjectReferencer->RemoveObject(this);
   }

   virtual void AddReferencedObjects( FReferenceCollector& Collector ) = 0;
};

静态指针 GGCObjectReferencer,用来管理所有继承了 GFGCObject 的指针对象;初始化执行如下两个方法

  • StaticInit() 内 AddToRoot;表示将 GGCObjectReferencer 设置为根节点,因此它永远不会被 GC 清理(除非调用 RemoveFromRoot)
  • AddObject(this) ,则是将自身放入 GGCObjectReferencer 内的ReferencedObjects 数组内

每一次GC的引用标记阶段,执行到 GGCObjectReferencer 指向的对象时,会执行它的 AddReferencedObjects 方法,而它会遍历调用 ReferencedObjects 数组内的所有 FGCObject 对象的 AddReferencedObjects 方法

//GCObjectReferencer.cpp
void UGCObjectReferencer::AddReferencedObjects(UObject* InThis, FReferenceCollector& Collector)
{	
    UGCObjectReferencer* This = CastChecked<UGCObjectReferencer>(InThis);
    for (FGCObject* Object : This->ReferencedObjects)
    {
	Object->AddReferencedObjects(Collector);
    }
}

我们来看看指针 ReferenceCollector 内的 AddReferencedObjects 方法:

//StrongObjectPtr.h
virtual void AddReferencedObjects(FReferenceCollector& Collector) override
{
   Collector.AddReferencedObject(Object);
}

Collector.AddReferencedObject( Object) 就是将 StrongObjectPtr 所修饰的 UObject 指针对象放入 GC 体系(被 Collector 引用,因此不会被 GC )

以上,我们解释了第一条:持有该对象,并且保证它不被 GC ;那怎么告诉 GC,我现在用完了,可以清理呢?

上面的 ~FGCObject 析构函数已经给到答案了,就是将 ReferenceCollector 从 GGCObjectReferencer 移除。那么下次 GC 引用标记阶段执行到 GGCObjectReferencer 指向的对象时,它的 ReferencedObjects内就没有我们的 ReferenceCollector,那么它的 AddReferencedObjects 方法就不会被执行到; 那么 ReferenceCollector 内的UObject* Object 就没人引用,那么 GC 就会清理它

其实,大部分情况下,都用不到 StrongObjectPtr ,因为 UPROPERTY 标记足够好用。然而它可以在任何地方使用,包括F类内,这也是它的优势

参考引用:

[原创]C++基础系列—智能指针

如果你熟悉 Java 或者 C# ,那么以下代码你肯定不陌生

class Students { public int value = 0; }

void main()
{
 Students a = new Students();
 Students b = a;
 a.value = 10;
//此时,变量 a 和变量 b 的 value 值都是 10
}

很简单,生成了一个Students对象,变量 a 和变量 b 都是对该对象的引用;但是在C++ 中,结果并不是这样

class Students
{
 public:
    int value = 0;
};

void main()
{
    Students a;
    Students b = a;
    a.value = 10;
    //此时,变量 a 的 value 是10,变量 b 的 value 仍是 0
}

编译器会为 class 默认生成 copy constructor 和 assignment operator,C++ 对象默认是可以拷贝的 ,因此 b = a,相当于拷贝了一份全新的对象。那要怎么实现类似Java 或 C# 的效果呢?C++ 就发明了指针这么个东西,它就是指向对象实际存储在内存中的地址,用法如下:

int main()
{
    Students* a = new Students();
    Students* b = a;
    a->value = 5;
    //此时,变量 a 和 b 的value 都是 5
    //delete a;
}

new做的事情就是,调用底层 malloc 获取 Students 类型大小的内存地址(一般会对字节补齐),然后调用 Students 的构造函数(如果没有,编译器会自动补全空构造),返回该对象的内存地址。从此,内存上的这一块地址就永远被占用,除非调用 delete 函数释放。但如果 delete 过早,其他函数还在用这个指针,就可能导致程序崩溃

void foo(Students* ptr)
{
    ptr->value = 10; //crash
}

void main()
{
    Students* ptr = new Students();
    delete ptr;
    foo(ptr);
}

使用裸指针(raw pointer)最大的问题就是,你不知道它当前的所有权(ownership)属于谁,因此也就很难判断应该在哪个位置 delete 它。C++ 编委会的巨佬们本着为了让程序员少掉头发的原则,设计了一个封装指针的类,同时赋予它一个响亮的名字: 智能指针( Smart Pointers )

既然也叫 “指针”,那么就要求它用起来跟普通指针没区别。因此这个封装裸指针的类重载了很多操作符,比如:* ,& ,bool ,== 等。针对各种使用场景,一共设计了三种智能指针类 :

  • std::unique_ptr(single ownership)
  • std::shared_ptr (shared ownership)
  • std::weak_ptr ( no ownership ,与 shared_ptr 伴生关系)

针对上面会崩溃的问题,我们代码改下如下:

void foo(std::unique_ptr<Students> ptr)
{
    ptr->value = 10; //no crash
}

void main()
{
    auto ptr =std::make_unique<Students>();
    foo(std::move(ptr)); 
}
// no memory leak

崩溃解决,同时也不会造成内存泄漏。构造 unique_ptr 的官方推荐的写法是: auto ptr = std::make_unique<class>(); 转移 ownership 的写法是 std::move(ptr); 为了能更详细的理解,我们来写个小实例,并打印相关讯息:

class Students
{
public:
    Students(){ std::cout << "Students初始化创建" << std::endl; }
    ~Students() { std::cout << "Students析构销毁" << std::endl; }
};

int foo(std::unique_ptr<Students> ptr2)
{
    std::cout << "进入foo函数" << std::endl;
    std::cout << "退出foo函数" << std::endl;
}

int main()
{
    std::cout << "进入main函数" << std::endl;
    //注释段1
    //{
        //std::cout << "进入main函数scope1" << std::endl;
        //auto ptr1 = std::make_unique<Students>();
        //std::cout << "退出main函数scope1" << std::endl;
    //}

    //注释段2
    //auto ptr2 = std::make_unique<Students>();
    //foo(std::move(ptr2));
    std::cout << "退出main函数" << std::endl;
}

打开注释段1,打印如下左边;打开注释段2,打印如下图右边

可以看出 ptr1 的 ownership 属于 block scope,scope代码执行完以后, ptr1 自动执行析构函数;ptr2 的 ownership 一开始属于 main 函数, 通过 std::move 转移给 foo 函数,因此一旦 foo 函数生命周期结束,ptr2 也会自动执行析构函数

看似很完美,然而 unique 的意思是唯一,也就是说这种智能指针的 ownership 只能唯一,因此如下代码,就会崩溃:

void foo(std::unique_ptr<Students> ptr)
{
    ptr->value = 10;
}

void bar(std::unique_ptr<Students> ptr)
{
    ptr->value = 10; //ptr is empty, crash
}

int main()
{
    auto ptr = std::make_unique<Students>();
    foo(std::move(ptr));
    bar(std::move(ptr));
}

原因是什么呢?std::move(ptr) 的意思就是把 ptr 的 ownership 从 main 函数转移给 foo 函数,转移后 ptr 也就没有 ownership了,自动析构了; 针对这种情况,我们就需要用到 shared_ptr , 使用规范如下:

跟 unique_ptr 相比,多了一个use_count。因为它就是用来记录,当前有多少个 ownership,为什么要记录个数呢?因为只有确定它最后一个 ownership 的生命周期结束以后,它才能自动析构。针对上面那个问题,我们用shared_ptr改写:

void foo(const std::shared_ptr<Students> ptr)
{
    puts("enter foo");
    std::cout << ptr.use_count() << std::endl;
    puts("exit foo");
}

int main()
{
    puts("enter main");
    const auto ptr = std::make_shared<Students>();
    std::cout << ptr.use_count() <<std::endl;
	
    {
	puts("enter scope");
	auto ptr_scope1 = ptr;
	std::cout << ptr.use_count() << std::endl;
	auto ptr_scope2 = std::move(ptr_scope1);//transfer ownership
	std::cout << ptr.use_count() << std::endl;
	puts("exit scope");
     }
	
    std::cout << ptr.use_count() << std::endl;
    foo(ptr);
    std::cout << ptr.use_count() << std::endl;
    puts("exit main");
}

打印结果如下:

也许下面这张图,你能理解的更透彻

shared_ptr 的 use_count 的增减做了防 data_race ,因此它默认支持多线程。目前来看,unique_ptr 和 shared_ptr 好像可以解决所有问题,为什么还需要一个 weak_ptr 呢?那么来看下面的情况:

class Students
{
public:
    Students() { puts("Students create"); }
    ~Students() { puts("Students destroy"); }
    std::shared_ptr<Students> next;
    //std::weak_ptr<Students> next;
};

int main()
{
    auto ptr_1 = std::make_shared<Students>();
    auto ptr_2 = std::make_shared<Students>();
    ptr_1->next = ptr_2;
    ptr_2->next = ptr_1;
    std::cout << ptr_1.use_count() << std::endl;
    std::cout << ptr_2.use_count() << std::endl;
}

//print:
//Student create
//Student create
//2
//2

哦豁,并没有打印 Student destroy;也即是说出现了 memory leak;智能指针失灵了?问题就出在赋值的那两句代码导致 use_count = 2,main函数退出后,use_count 仍然为1,因此不会执行析构。因此如果有一种指针,我想用shared_ptr,但又不想它 use_count 有变化,只是在用的时候,检查一下它还在不在,那不就完美了吗?因此,上面的注释打开,就能正确析构。详细用法如下:

    auto ptr = std::make_shared<Students>();
    std::cout << ptr.use_count() << std::endl;
    {
	//必须从 shared_ptr 构造(不会增加 shared_ptr 的 use_count)
	std::weak_ptr<Students> ptr_w = ptr;
		
	//必须从weak_ptr 重新转换成 shared_ptr 再使用
	//ptr的 use_cunt + 1
	std::shared_ptr<Students> ptr_w2s = ptr_w.lock();
	if(ptr_w2s)
	{
	    std::cout << ptr.use_count() << std::endl;
	}
    }	
    std::cout << ptr.use_count() << std::endl;
    //print: 1 2 1

打印结果与预期的结果一致。因此,很多时候,为了避免循环引用,一般shared_ptr 和 weak_ptr 配合使用。官方给出的使用建议是:

  • 尽量使用智能指针替代裸指针
  • 优先使用std::unique_ptr 而不是 shared_ptr (可以想想为什么?)
  • 尽可能 std::move(share_ptr)而不是直接 foo(share_ptr) (理由同上)

内容参考:

  1. B站 花花酱的表世界 Smart Pointers 智能指针 – C++ Weekly EP3
  2. 知乎用户 sin1080 关于 c++ 11 的shared_ptr多线程安全?的优秀回答
  3. C++ ownership semantics

[译文]5分钟系列—快速理解Multithreading(多线程)

原文链接:A gentle introduction to multithreading

前言

随着计算机硬件 ( hardware ) 的更新迭代以及操作系统 ( operating systems ) 的智能化,使得现代计算机能够同时处理更多任务;从而让我们的应用程序执行速度更快,响应时间更短

因此编写软件的时候,我们可以利用并发处理越来越多的事情;但凡事都有代价,这要求我们对并发的实现原理要有更深入的了解。让我们用 threads (线程)来开启并发的魔法之旅吧

Processes and threads ( 进程与线程 ) : 字如其意

目前主流的操作系统都能同时运行多个程序,这也是为什么你能用 Chrome 浏览器看这篇文章的同时,还能用网易云音乐听歌。因为每个程序都运行在一个所谓 进程 的环境中;操作系统为每个进程提供不同的硬件支持 ( 包括 CPU,内存,显存,输入输出等)

让操作系统同一时间执行多个任务,并不是只有启动多个进程这一个办法。每个进程可以在其内部同时开启多个 线程 用来并发执行任务。你可以把线程看作是进程的一部分,每个进程启动的时候,至少会开启一个线程,一般称这个线程为:主线程。程序员可以根据实际需求,开启或者停止其他线程用来执行任务;我们经常说的多线程,指的就是在一个进程中,开启多个线程

我们用来听歌的网易云音乐,就是个多线程程序。主线程用来渲染UI界面,子线程1 用来播放音乐,子线程2 用来读取歌词并显示等。你可以通俗的把操作系统看成是进程的容器 ( 其实更像是管理员 ) ,进程是线程的容器,示例图如下:

进程和线程的区别

操作系统为每个进程分配一块独立的内存空间;默认情况下,进程之间不共享内存空间 ( 也就是不能相互访问 ) ,比如:Chrome浏览器无法访问网易云音乐的运行内存,反之亦然。除非使用进程通讯技术 inter-process communication (IPC)

与进程不同,线程能够共享父进程的内存空间。比如: 网易云音乐的UI界面线程可以访问音乐播放线程的内容,反之亦然。同时,线程占用资源更少,创建/切换 速度更快,也有人将线程称为:  lightweight processes (轻量级进程)

因此,相对启动多进程(通讯不方便,更消耗资源),启用多线程才是同时处理多任务的最佳实现,而且程序能运行的更快

多线程用处

为什么需要多线程?因为并发执行任务能大大节省时间提高效率。举个不太恰当的例子:比如你下载一部电影,如果单线程下载,需要1小时;但是多线程下载,就可以根据线程数去下载不同的片段,多任务同时进行,那么就可以很快下完

多线程太完美了,真的就这么简单吗?以下几个问题你要好好考虑:

  • 不是所有程序都要用到多线程。如果你的程序本身就需要顺序执行;或者频繁等待用户输入,那就没有必要多线程
  • 线程不是越多越好。要根据具体任务的类型思考和设计,是否增加线程
  • 多线程并不保证真正做到并发执行。这还要取决硬件是否支持

最后一点请注意,并不是所有硬件设备都支持同一时刻执行多任务,只不过操作系统让它看起来像,这个后面会介绍

我们先把 并发(Concurrency) 看作是多任务看起来是同时执行;而 并行(Parallelism) 是看作是同一时刻处理多任务。举个简单的例子来理解并发和并行

并发:吃饭的时候,来了电话;停止吃饭,接电话

并行:吃饭的时候,来了电话;边吃饭边打电话(并行也不一定高效,吃饭的时候说话对方有可能听不清楚,导致打电话时长增加)

并发(Concurrency),并行(Parallelism)

CPU是程序被执行的地方,它由很多部分组成,其中最核心的部分称之为:Core (核心),每个核心同一时刻只能执行一个操作。硬件设计如此,因此操作系统为了能最大化利用核心,发明了很多先进技术,使得用户能够运行多个进程,其中最重要的技术就是:抢占式多任务处理机制。抢占是指中断当前任务,切换到另一个任务,稍后再切回当前任务继续执行

因此,如果你的硬件是单核CPU,那么操作系统的工作就是将单核的算力分配到不同的进程或线程上,这些进程或者线程会被轮询执行;这样就会给我们造成一个假象:同时多个程序被执行 或者一个程序同时执行多个任务(多线程)。虽然这满足了并发,但实际上并未真正并行

如今CPU早就多核心,每个核心同一时刻都能进行一个操作,这也意味着真正的 并行 是可能的。操作系统会自动检测CPU的核心数量,并为每个核心配置进程/线程。一个进程/线程运行在哪个CPU核心对我们来说是完全透明的,而且一旦所有核心都很忙,那么抢占式多任务处理就会启动。这也是为什么计算机能同时开启的进程数量远超CPU核心数的原因

对于单核CPU多线程有意义吗?

在单核CPU上,真正实现并行是不可能的。不过,多线程依然是有意义的,因为抢占式任务处理的存在,它可以使得应用程序保持响应,即使其中一个线程执行缓慢或阻塞的任务

举个例子:假如你的应用要从磁盘读取某个大文件,此时如果是单线程,那么整个应用会未响应状态。如果用多线程,那么就可以线程A去读取文件,线程B刷新UI界面(实时观测读取该文件的进度)

多线程,多问题

依上文可知,线程会共享其父进程的内存空间;这就使得同个进程内的多线程之间可以很方便的进行数据交互。多个线程从同一块内存地址中读取数据没有任何问题,但是一旦某个线程进行写操作,而其他线程进行读操作时就容易出现如下问题:

  • Data Race(数据竞争):线程A 对一块内存地址的内容形成写操作,而 线程B 又同时读取这块内存地址存储的值,那么就有可能读到的是脏数据
  • Race Condition(竞争条件):线程执行的顺序是不可控的,而我们程序又希望按照指定的顺序执行。比如:我们要求 线程B 只能在 线程A 完成写入操作后再读取数据

如果一段代码,很多线程同时调用它而且不会有 数据竞争 和 竞争条件 ,那么我们称这段代码 thread-safe(线程安全)

数据竞争的根本原因

CPU内核一次只能执行一条机器指令。因其不可分割性,我们称这种指令为 原子指令(它不能被分解成更多步骤的操作)。希腊单词 atom(ἄτομος;atomos)的意思是不可切割的

因不可分割的特性,原子操作天然的线程安全:当 线程A 的写操作具备原子性时,其他线程在 写操作指令 完成之前则无法执行 读操作指令;反之亦然,因此不会发生数据竞争

可问题就是,大部分的操作指令都不是原子指令。即使是像 i++ 这样简单赋值也是由多个原子指令组成 (可以思考以下,为什么它不是原子操作,面试官很喜欢问的一个问题),从而使赋值本身作为一个整体是非原子的。因此,如果一个线程B 读取 i 而线程A 执行赋值,就会触发数据竞争

竞争条件的根本原因

抢占式多任务处理机制使得操作系统能够完全掌控和管理线程:它可以根据调度算法启动、暂停和中止线程;作为程序的开发者,你并不能控制线程的执行时间和顺序。实际上,你并不能保证如下代码会按照指定的顺序执行(一般是指从上到下)

writer_thread.start()
reader_thread.start()

运行多次,你就会发现有时 writer 线程先启动,有时 reader 线程先启动。如果你的程序需要保证先执行写操作再执行读操作,那么你肯定会遇到竞争条件

这种行为被称为不确定性:结果每次都会改变,你无法预测。调试受竞争条件影响的程序非常烦人,因为产生的bug是非必现的,而且有可能在你的计算机运行不会出问题,而在别人的计算机上就会有问题

多线程与并发

多线程编程,数据竞争 和 竞争条件 是经常会出现的问题。容纳多个并发线程的技术称之为: concurrency control (并发控制),操作系统或者一些编程语言为我们提供了一些解决方案,比较通用的是如下几种:

  • synchronization 同步: 确保同一时刻仅有一个线程在使用资源。将代码的特定部分作上标记,这样多个并发线程就不会同时执行这段代码,也不会让共享数据变得混乱
  • atomic operations  原子操作: 借助操作系统提供特殊指令,将非原子操作(如:赋值操作)转化为原子操作 ( 比如C#的 Interlocked.Increment )
  • immutable data 不可变数据: 当数据被标记为不可变(ReadOnly),只允许线程从中读取数据,而不允许线程改变数据内容

以上三种只是比较常规的做法,C# 内关于多线程的解决,还有一些其他方案,比如:监视器(Monitor)、互斥锁(lock)、读写锁(ReadWriteLock)、互斥(Mutex)、信号量(Semaphore)、事件(AutoResetEvent/ManualResetEvent)等