防止异步函数初始化之前访问静态属性

3

我有一个函数,它异步加载xml文件,解析它,并将某些值添加到列表中。 我正在使用async和await来实现这一点。 我遇到的问题是,在调用await之后,程序会继续执行访问该列表的代码,而异步函数尚未完成添加所有项目。

我的静态类与异步函数:

Original Answer: "最初的回答"
using System;
using System.Threading.Tasks;
using System.Collections.Generic;
using System.Xml.Linq;

using UnityEngine;
using UnityEngine.ResourceManagement.AsyncOperations;
using UnityEngine.AddressableAssets;

namespace Drok.Localization
{
    public static class Localization
    {
        /// <summary>
        /// The currently available languages.
        /// </summary>
        public static List<string> Available { get; private set; } = new List<string>();
        /// <summary>
        /// The currently selected language.
        /// </summary>
        public static string Current { get; private set; } = null;

        public static async Task Initialize()
        {
            await LoadMetaData();
        }

        private static async Task LoadMetaData()
        {
            AsyncOperationHandle<TextAsset> handle = Addressables.LoadAssetAsync<TextAsset>("Localization/meta.xml");
            TextAsset metaDataFile = await handle.Task;
            XDocument metaXMLData = XDocument.Parse(metaDataFile.text);
            IEnumerable<XElement> elements = metaXMLData.Element("LangMeta").Elements();
            foreach (XElement e in elements)
            {
                string lang = e.Attribute("lang").Value;
                int id = Int32.Parse(e.Attribute("id").Value);
                Debug.LogFormat("Language {0} is availible with id {1}.", lang, id);
                Available.Add(lang);
            }
        }

        public static void LoadLanguage(string lang)
        {
            Current = lang;
            throw new NotImplementedException();
        }

        public static string GetString(string key)
        {
            return key;
        }
    }
}

初始化并访问列表的类:

最初的回答:

using Drok.Localization;

using UnityEngine;

namespace Spellbound.Menu
{
    public class LanguageMenu : MonoBehaviour
    {
        private async void Awake()
        {
            await Localization.Initialize();
        }

        private void Start()
        {
            Debug.Log(Localization.Available.Count);
        }

        private void Update()
        {

        }
    }
}

我不知道如何在所有项目都添加完之前防止访问该列表。我发布的代码只是收集可用语言的信息,以便稍后只能加载使用的一种语言。"最初的回答"

2
get访问器中添加一些逻辑,以确保在返回列表之前完成初始化。 - Rufus L
6个回答

3
一个 Task<T> 代表了一些未来会被确定的值(类型为 T)。如果您将属性设置为这种类型,那么它将强制所有调用者等待它被加载:

public static class Localization
{
  public static Task<List<string>> Available { get; private set; }

  static Localization() => Available = LoadMetaDataAsync();

  private static async Task<List<string>> LoadMetaDataAsync()
  {
    var results = new List<string>();
    ...
      results.Add(lang);
    return results;
  }
}

使用方法:

private async Task StartAsync()
{
  var languages = await Localization.Available;
  Debug.Log(languages.Available.Count);
}

使用方法是 Debug.Log(languages.Count);,对吗? - Rufus L
这似乎是我目前情况下最可行的选择。我只是希望有一种方法可以避免每次需要访问它时都要重复使用'await'。 - Drok_

2

可能的一种方法是在从get访问器返回列表时添加一些逻辑以等待元数据加载。

一种方法是设置一个布尔字段,当列表准备好后将其设置为true,然后根据我们的bool字段的值返回私有支持List<string>null

最初的回答:

可以在get访问器中添加一些逻辑来等待元数据加载,以便返回列表。一种方法是设置一个布尔字段,当列表准备好后将其设置为true,然后根据我们的bool字段的值返回私有支持List<string>null

public static class Localization
{
    private static bool metadataLoaded = false;
    private static List<string> available = new List<string>();

    // The 'Available' property returns null until the private list is ready
    public static List<string> Available => metadataLoaded ? available : null;

    private static async Task LoadMetaData()
    {
        // Add items to private 'available' list here

        // When the list is ready, set our field to 'true'
        metadataLoaded = true;
    }
}

这似乎更接近我所需的东西,但主要问题是我得到了null而不是正确的值。可能有一种方法只是告诉它等待直到完成,而不仅仅返回null吗? - Drok_
public static List<string> Available {get{while(!metadataLoaded); return available;}}这段代码会等待元数据加载完成,但在等待期间可能会导致客户端出现卡顿。例如,如果在调用Initialize之前尝试访问列表,它们将永远等待下去。 - Rufus L
嗯,即使我在访问Available之前手动调用LoadMetaData或者将其放在静态构造函数中也会导致程序卡住。 - Drok_
@StephenCleary的回答非常好,可以让客户端await集合,这可能是你最好的选择。 - Rufus L
那似乎是目前我最好的选择。我试图避免重复的代码,即等待(await)。 - Drok_

2

Awake方法是异步的void,因此调用者无法保证它在继续执行其他操作之前完成。

但是,您可以保存该任务并在Start方法中等待它以确保它完成。两次等待不会造成任何影响。

最初的回答:

Awake方法是异步的void,因此没有办法保证调用者在继续进行其他操作之前已经完成。但是,您可以在Start方法中保留任务并等待它完成,这样就能够确保它完成。等待两次不会对任何事情造成伤害。

public class LanguageMenu : MonoBehaviour
{
    private Task _task;

    private async void Awake()
    {
        _task = Localization.Initialize();
        await _task;
    }

    private async void Start()
    {
        await _task;
        Debug.Log(Localization.Available.Count);
    }

    private void Update()
    {

    }
}

我觉得可以这样做,但是这个列表和其他稍后要初始化的东西将在很多不同的地方被访问,所以保持检查是否已完成似乎不合理,对吗? - Drok_
有没有可能以同步的方式处理加载时的异步调用?或者有没有其他的方法,在需要时将文件加载到内存中,而不是使用异步方式。如果有需要的话,我在原帖中详细说明了我的用例。 - Drok_
当然,从所有这些方法中移除async,让它们以同步方式运行。你一开始为什么要添加async呢? - John Wu
如果我没有等待加载,记忆没错的话,我会遇到空指针异常。 编辑:是的,刚刚检查了一下,如果我将所有方法改为非异步,并且说“_TextAsset metaDataFile = handle.Result;_”,当我尝试解析它时,我会得到一个空指针。我猜这是因为加载方法仍然是异步的,因为我无法更改它。 - Drok_
你试过使用LoadAsset而不是LoadAssetAsync吗? - John Wu
这是既被弃用又返回AsyncOperationHandler的代码。看起来它应该是同步的,但实际上并不是。 - Drok_

1

扩展Rufus的评论:

声明一个布尔属性,其初始化为false。在列表的getter中,仅当该布尔属性为true时返回列表,并在false时返回null(这取决于您的要求)。

public static bool IsAvailable { get; set; } = false;

private static List<string> _available;
public static List<string> Available
{
    get
    {
        if (IsAvailable)
            return _available;
        else
            return null;
    }
    set { _available = value; }
}

最后,在您的async函数中,当工作完成时将上述属性设置为true。


0
当涉及到需要等待执行的Update方法时,使用asyncawait可能还不够。通常,对于Unity消息来说,总是有一个更好的选择:事件系统,例如。
public static class Localization
{
    public static event Action OnLocalizationReady;

    public static async void Initialize()
    {
        await LoadMetaData();

        OnLocalizationReady?.Invoke();
    }

    ...
}

在任何使用它的类中等待该事件,例如:

public class LanguageMenu : MonoBehaviour
{
    private bool locaIsReady;

    private void Awake()
    {
        Localization.OnLocalizationReady -= OnLocalizationReady;
        Localization.OnLocalizationReady += OnLocalizationReady;

        Localization.Initialize();
    }

    private void OnDestroy ()
    {
        Localization.OnLocalizationReady -= OnLocalizationReady;
    }

    // This now replaces whatever you wanted to do in Start originally
    private void OnLocalizationReady ()
    {
        locaIsReady = true;

        Debug.Log(Localization.Available.Count);
    }

    private void Update()
    {
        // Block execution until locaIsReady
        if(!locaIsReady) return;

        ...
    }
}

或者为了更好的性能,你也可以在Awake中将enabled设置为false,并在OnLocalizationReady中将其设置为true,这样你就可以摆脱locaIsReady标志。
不需要使用asyncawait
如果你把 Localization.Initialize(); 这行代码移到 Start 方法中,其他类也有机会在 Awake 方法中的 Localization.OnLocalizationReady 之前添加一些回调函数。 ;)

而且你可以以多种方式扩展它!例如,你可以直接与事件一起传递对Availables的引用,这样监听器就可以直接使用它,如:

public static class Localization
{
    public static event Action<List<string>> OnLocalizationReady;

    ...
}

然后在LanguageMenu中将OnLocalizationReady的签名更改为
public class LanguageMenu : MonoBehaviour
{
    ...

    // This now replaces whatever you wanted to do in Start originally
    private void OnLocalizationReady(List<string> available)
    {
        locaIsReady = true;

        Debug.Log(available.Count);
    }
}

如果无论如何,LanguageMenu将是唯一的监听器,那么你甚至可以直接将回调作为参数传递给Initialize函数。
public static class Localization
{
    public static async void Initialize(Action<List<string>> onSuccess)
    {
        await LoadMetaData();

        onSuccess?.Invoke();
    }

    ...
}

然后像这样使用它

private void Awake()
{
    Localization.Initialize(OnLocalizationReady);
}

private void OnLocalizationReady(List<string>> available)
{
    locaIsReady = true;

    Debug.Log(available.Count);
}

或者使用lambda表达式

private void Awake()
{
    Localization.Initialize(available => 
    {
        locaIsReady = true;

        Debug.Log(available .Count);
    }
}

更新

关于你关于后期初始化的问题:是的,也有一个简单的解决方法

public static class Localization
{
    public static event Action OnLocalizationReady;

    public static bool isInitialized;

    public static async void Initialize()
    {
        await LoadMetaData();

        isInitialized = true;
        OnLocalizationReady?.Invoke();
    }

    ...
}

然后在其他类中,你可以根据条件使用回调函数或立即进行初始化。
private void Awake()
{
    if(Localization.isInitialized)
    {
        OnLocaInitialized();
    }
    else
    {
        Localization.OnInitialized -= OnLocaInitialized;
        Localization.OnInitialized += OnLocaInitialized;
    }
}

private void OnDestroy ()
{
    Localization.OnInitialized -= OnLocaInitialized;
}

private void OnLocaInitialized()
{
    var available = Localization.Available;

    ...
}

private void Update()
{
    if(!Localization.isInitialized) return;

    ...
}

谢谢你的回答,但至少对于我的情况,我无法保证所有需要访问该事件的对象在调用之前都能监听到它。有没有办法解决这个问题呢? - Drok_
确实是的...今天刚用过;) 更新了我的答案。 - derHugo

0
我曾经遇到一个类似的问题,它只是真正破坏了测试工具,其工作原理如下。在此之后,我提供了解决这个类似/可能相同问题的方法。
该网站使用的数据很少更新,但非常频繁地被访问。数据存储在数据库中的结构化数据中。它的投影在应用程序启动时加载一次,以便在内存中快速访问。
网站上的大多数页面并不关心这些数据是否已加载,而那些关心的页面在网站启动时可能不会被访问,因为它们位于多页流程的几个步骤之后。但是一旦用户达到那个阶段,这些数据将被反复访问,直到几次部署后才会发生变化,当整个应用程序池重置时,此缓存将被更新。
因此,数据通过同步静态getter进行访问,例如:
DataCache.O

这是设置的样子:
public class MvcApplication : System.Web.HttpApplication
{
    protected void Application_Start()
    {
        TaskHelper.RunBg(DataCache.Init);

在这里,TaskHelper.RunBg只是在后台线程上运行一个异步方法(我们不希望用异步方法阻塞同步的Application_Start())。

public class DataCache
{
    public static DataCache O { get; protected set; }

    public static async Task Init()
    {
        using(var db = DbFactory.O.CheapReads())
        {
            await Load(db);

问题在于测试工具代码加载并击中重要内容的速度比正常的生产代码快得多,所以很常见测试会因为在应用初始化准备好之前就请求了这个DataCache而导致错误。我的解决方案是将任务提供给测试工具代码:
public class DataCache
{
    public static DataCache O { get; protected set; }

    public static Task<DataCache> LoadingTask { get; protected set; }

    public static async Task Init()
    {
        using(var db = DbFactory.O.CheapReads())
        {
            await Load(db);
        }
    }

    public static async Task<DataCache> Load(Db db)
    {
        LoadingTask = load(db);
        return await LoadingTask;
    }

    protected static async<DataCache> load(Db db)
    {
        ... DataCache loading code goes here

然后在代码中,测试工具可能会用来访问这个的地方,替换为:
var dataCache = DataCache.O;

使用:

var dataCache = await DataCache.LoadingTask;

根据测试工具调用的不同,这可能意味着会生成多个订阅线程,所有线程都在等待 - 基本上都订阅了这个加载任务的完成事件。TPL会为您处理多个订阅者的问题,无需额外工作。
虽然这意味着大部分时间等待返回是同步的,我还没有测试过,但似乎TPL很乐意优化那些大部分时间返回同步的异步方法,因此性能影响应该可以忽略不计(而且它主要用于测试代码)。而且当它以异步方式运行时,在数据尚未准备好时会阻止访问静态数据。
公平地说,也许更明智的做法是永远不要同步提供这些数据,而始终只通过那个LoadingTask来提供。
有些情况下这仍然不够 - 我的测试代码甚至在MvcApp Init设置它之前就开始请求LoadingTask,更别提完成它了。这可以通过使用TaskCompletionSource来解决:
public class DataCache
{
    protected static TaskCompletionSource<DataCache> loadingTcs = new ...

    public static Task<DataCache> LoadingTask
    {
        get => loadingTcs.Task;
    }

    public static Task<DataCache> Load(Db db)
    {
        ... run the long-running Db query
        O = (the loaded data)
        loadingTcs.SetResult(O);
        return O;
    }

现在您已经有一个立即提供任务的类,即使初始化代码还没有开始启动它,但在初始化代码启动后,它不会将任务标记为已完成,直到运行结束。

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接