Press "Enter" to skip to content

Unity a multithreading

Zechy 0

Říká se, že pokud můžete, multithreadingu se vyhněte. Je pravda, že jak do aplikace dotáhnete přístup přes vícero vláken, začíná teprve opravdová jízda, protože najednou musíte přemýšlet, jak pracujete s datama.

Ale někdy není zbytí, občas přece jenom musíte hlavnímu vláknu ulehčit a něco zpracovat paralelně. Často jsou takové operace potřeba i v Unity, a i když vás od toho Unity na každém kroku zrazuje, přece jenom to jde.

V Sagittaras Games máme vlastní networking framework, pomocí kterého dokážeme sestavit server, připojit se s klientem a všechno si povídá jedna báseň. Full multithreading. V tenhle okamžik přichází ta situace, kdy se na vedlejším vláknu točí objekt, který naslouchá příchozí komunikaci. Jakmile obdrží zprávu, zavolá se událost. A tady začíná ta sranda…

Problémy vláken a Unity

Na první pohled udělat něco přes vlákna je v Unity nereálné. Veškeré třídy, které vám Unity nabízí vám to jednoduše zakazují, protože jakmile k nějaké operaci přistoupíte z jiného vlákna, dostanete výjimku. Pojďme se podívat na náš networking. Máme k dispozici událost MessageReceived, jejíž parametry jsou tradičně sender (kdo událost spustil) a nějaké EventArgs, které v sobě obsahují přijatou zprávu.

ISagittarasClient client = new SagittarasClient(connection, logger);
client.MessageReceived += (sender, args) => {
  Debug.Log("V tento okamžik jsme obdrželi naší zprávu!");
};

Můžete si říct, však to je přece jednoduché. Přivěsím callback a prostě počkám na výsledek…

Jenomže algoritmus, který naslouchá příchozí komunikaci ze serveru běží ve svém vlastním vlákně. A tak v okamžik kdy je zpráva přijata, se callback spouští právě v tomto naslouchajícím vlákně a ne v tom hlavním, na kterém je spuštěné Unity. Pokud tedy potřebujete například odstranit v UI hlášku Čekám na odpověď serveru, pohoříte.

TaskCompletionSource

Pokud potřebujete v C# počkat na výsledek operace z jiného vlákna, nabízí se efektivní thread safe přístup, kterým je TaskCompletionSource. Tato třída vám poskytuje vlastnost, která obsahuje Task, kterému následně můžete předat výsledek, či jej zrušit. Pojďme si tedy zkusit trošku poupravit náš výchozí kod.

public class MyParallelTask {
	private readonly TaskCompletionSource<Message> _completionSource;
	private readonly ISagittarasClient _client;
	public MyParallelTask() 
	{
		_client = new SagittarasClient();
		_client.MessageReceived += OnMessageReceived;
		CancellationTokenSource cancellation = new CancellationTokenSourcen(60 * 1000);
		_completionSource = new TaskCompletionSource<Message>(cnacellation.Token);
		cancellation.Token.Register(() => {
			if(_completionSource.Task.IsCompleted) {
				return;
			}
			_completionSource.SetCanceled();
		});
		client.Send(new Message());
	}
	private void OnMessageReceived(object? sender, MessageReceivedEventArgs e) 
	{
		_completionSource.SetResult(e.Message);
	}
}

Nebudeme se tedy zajímat o to, jak vlastně vytvoříme instanci klienta, stejně tak nemusíme dávat nějaký hlubší kontext odesílané zprávě.

Malým šikovným bonusem je třída CancellationTokenSource, které lze nadefinovat timeout. Pomocí metody Register na Tokenu, pak můžeme pomocí callbacku nastavit, co se má stát, jakmile čas vyprší. V našem případě tak tuto operaci zrušíme, protože server neodpověděl včas. Je samozřejmě nutné myslet na to, že IsCompleted nesmí být true, jinak volání SetCanceled() skončí výjimkou.

V okamžik, kdy klient obdrží odpověď ze serveru, tak do našeho TaskCompletionSource předáme výsledek. Následně můžeme pomocí vlastnosti Task číst, zda operace je IsCompleted (dokončená, ať s chybou nebo bez), IsCanceled (Zrušená) či IsFaulted (Byla zachycena výjimka). A samozřejmě si i přečíst Result, kde se skrývá náš výsledek.

No jo, ale jak s tím pracovat v Unity?

Custom Yield Instruction

Unity nabízí metodiku tzv. Coroutines. Tyto metody jsou spuštěné při každém framu do okamžiku, dokud se nenarazí na instrukci yield, jakmile se na ní narazí, Unity v tomto místě přeruší operaci a pokračuje dále až na dalším framu. Určitě jste se tak již setkali s třídami jako třeba WaitForFixedUpdate, WaitForSeconds a podobně.

Podobnou třídu si snadno můžeme napsat také a ohnout ji, aby zastavila coroutine do okamžiku, než je výsledek k dispozici, k tomu nám stačí použít třídu CustomYieldInstruction a rozšířit tak náš kod

public class MyParallelTask : CustomYieldInstruction {
	private readonly TaskCompletionSource<Message> _completionSource;
	private readonly ISagittarasClient _client;
	public MyParallelTask() 
	{
		_client = new SagittarasClient();
		_client.MessageReceived += OnMessageReceived;
		CancellationTokenSource cancellation = new CancellationTokenSourcen(60 * 1000);
		_completionSource = new TaskCompletionSource<Message>(cnacellation.Token);
		cancellation.Token.Register(() => {
			if(_completionSource.Task.IsCompleted) {
				return;
			}
			_completionSource.SetCanceled();
		});
		client.Send(new Message());
	}
  	// Nutno implementovat.
	public override bool keepWaiting => !_completionSource.Task.IsCompleted;
	public Message Result 
	{
		get
		{
			if(_completionSource.Task.IsCanceled) 
			{
				throw new TimeOutException();
			}
			return _completionSource.Task.Result;
		}
	}
	private void OnMessageReceived(object? sender, MessageReceivedEventArgs e) 
	{
		_completionSource.SetResult(e.Message);
	}
}

CustomYieldInstruction po vás vyžaduje implementaci vlastnosti keepWaiting. Dokud je tato hodnota true, Coroutine stojí na místě. V našem případě tak budeme čekat do okamžiku, než je Task označený jako IsCompleted.

Výsledek z této metody získáme pomocí vlastní vlastnosti Result, a pokud náhodou IsCanceled je true, značící, že doběhl náš timeout, vyhodíme výjimku. Jinak navrátíme náš výsledek. Celé to tak dáme do pohybu následujícím kódem.

public class ParallelBehaviour : MonoBehaviour {
	public TextMeshProUGUI StatusLabel;
	private void Start() 
	{
		StatusLabel.text = "Čekám na odpověď serveru";
		StartCoroutine(ParalellCoroutine());
	}
	private IEnumerator ParalellCoroutine() 
	{
		MyParallelTask task = new MyParallelTask();
		yield return task;
		try 
		{
			Message message = task.Result;
			StatusLabel.text = $"Získal jsem svou Message! {message}";
		} 
		catch(TimeOutException) 
		{
			StatusLabel.text = "Server neodpověděl včas";
		}
	}
}

Náš task tak v klidu na pozadí odešle zprávu na server a vyčkává, dokud server nevrátí svou odpověď. A jelikož to máme jako Custom Yield Instruction, coroutine stojí každý frame na yield return task, dokud se keepWaiting nezmění na false. Jakmile se tak stane, vyzvedneme si svůj výsledek a aktualizujeme UI. A buď nám to projde, nebo server neodpověděl včas.

Napsat komentář