I am trying to cache Texture2D in memory to avoid frequent api calls for already called image urls.
Below is the code I came up with -
using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine.Networking;
using UnityEngine;
public class TextureCache : MonoBehaviour
{
struct CacheData {
public Texture2D texture;
public long expiry;
};
private static Dictionary<string, CacheData> cache = new Dictionary<string, CacheData>();
// TODO: make multiple locks
private static readonly object cacheLock = new object();
private static void add(string key, Texture2D texture, long ttl)
{
CacheData data = new CacheData();
data.texture = texture;
data.expiry = DateTimeOffset.Now.ToUnixTimeSeconds() ttl;
Debug.Log("cache adding -- " key);
cache.Add(key, data);
}
private static Texture2D get(string key)
{
bool found = cache.ContainsKey(key);
if (!found)
{
Debug.Log("cache not found " key);
return null;
}
// if key has expired
if (DateTimeOffset.Now.ToUnixTimeSeconds() > cache[key].expiry)
{
Debug.Log("cache expired " key);
cache.Remove(key);
return null;
}
Debug.Log("cache got " key);
// NOTE: prone to race condition when 1 thread reaches here but another thread runs cache.Remove at the same time
return cache[key].texture;
}
public static IEnumerator Get(string key, Action<Texture2D> callback)
{
Texture2D texture = get(key);
if (texture != null)
{
callback(texture);
}
else
{
lock (cacheLock)
{
Debug.Log("cache lock taken");
texture = get(key);
if (texture != null)
{
callback(texture);
}
else
{
Debug.Log("cache downloading " key);
// TODO: check if we can specify a timeout for this request
UnityWebRequest www = UnityWebRequestTexture.GetTexture(key);
yield return www.SendWebRequest();
if (www.result != UnityWebRequest.Result.Success)
{
Debug.Log(www.error);
}
else
{
Debug.Log("cache downloaded " key);
texture = ((DownloadHandlerTexture)www.downloadHandler).texture as Texture2D;
}
if (texture != null)
{
Debug.Log("cache adding " key);
add(key, texture, 300);
}
callback(texture);
}
}
}
callback(null);
}
}
And I am calling it like -
public IEnumerator GetTexture(string url, Action<Texture2D> callback)
{
Debug.Log(url);
Texture2D texture;
yield return StartCoroutine(TextureCache.Get(url, response => {
callback(response);
}));
}
But what I observe in logs is (after running in Unity Editor) -
cache not found https://host.ext/path/placeholder.png
cache lock taken
cache not found https://host.ext/path/placeholder.png
cache downloading https://host.ext/path/placeholder.png
cache not found https://host.ext/path/placeholder.png
cache lock taken
cache not found https://host.ext/path/placeholder.png
cache downloading https://host.ext/path/placeholder.png
cache downloaded https://host.ext/path/placeholder.png
cache adding https://host.ext/path/placeholder.png
cache adding -- https://host.ext/path/placeholder.png
cache downloaded https://host.ext/path/placeholder.png
cache adding https://host.ext/path/placeholder.png
cache adding -- https://host.ext/path/placeholder.png
ArgumentException: An item with the same key has already been added. Key: https://host.ext/path/placeholder.png
System.Collections.Generic.Dictionary`2[TKey,TValue].TryInsert (TKey key, TValue value, System.Collections.Generic.InsertionBehavior behavior) (at <e40e5a8f982c4b618a930d29f9bd091c>:0)
System.Collections.Generic.Dictionary`2[TKey,TValue].Add (TKey key, TValue value) (at <e40e5a8f982c4b618a930d29f9bd091c>:0)
TextureCache.add (System.String key, UnityEngine.Texture2D texture, System.Int64 ttl) (at Assets/TextureCache.cs:25)
TextureCache <Get>d__5.MoveNext () (at Assets/TextureCache.cs:83)
UnityEngine.SetupCoroutine.InvokeMoveNext (System.Collections.IEnumerator enumerator, System.IntPtr returnValueAddress) (at <4a31731933e0419ca5a995305014ad37>:0)
I expected that because of lock (cacheLock) line no 2 coroutines should do api call but it seems like its not working and multiple corountines are calling the image url concurrently and I keep getting exception -
ArgumentException: An item with the same key has already been added. Key: https://host.ext/path/placeholder.png
Question - How can I surround my api call with some kind of mutex/lock so that only 1 api call happens at a time while other coroutines wait for their turn (to check cache again and then do the api call)?
CodePudding user response:
Unity doesn't like multithreading, you can't access any of Unity's library from another thread (or your results are undefined). Everything generally runs on a single thread ("generally", meaning your own code - obviously Unity is multithreaded and you can spawn threads as well). It seems like what you want are threads and await/async, and there is a library called UniTask for doing this. The _lock idea is useless here, just remove it and think in regular ways of handling it. You can also rely on the fact that none of the code will run at the same time, each call into your coroutine will happen synchronously. So simply things like a static boolean etc will work. If you use a static boolean like "isInCall" and put a simple "yield return new WaitUntil(boolean is false)" and immediately set it to True after that line, and False once you are done with the web request, then everything may just work. Unity coroutines are not pre-emptive or multithreaded, it time slices so it will run one copy of the coroutine at a time even when multiple of it are running (the rest are waiting to run, and not running in parallel). Couroutines are basically the same as Update() but with more overhead and having lots of them running even worse than having lots of Update() running. Hope this helps.