The Many Types and Dangers of Callbacks

Datetime:2016-08-22 22:16:53          Topic: .Net           Share

Callbacks are a mainstay of the real-time games and apps we build in Unity. We’re constantly writing asynchronous code for every operation from walking a character to a destination to making a web call. It’s really convenient for these functions to “call back” and report their status so we know how the operation is going. Unfortunately there are also a lot of dangers that come along with this. Today we’ll look into the surprisingly large number of ways you can “call back” in C# and some of the ways you can get burned doing so.

The first kind of callback is the most obvious one: a delegate as a function parameter.

// Loads a text file in another thread
// This unblocks the main thread for rendering, input, etc.
// Calls the "callback" parameter with the loaded text when complete
void LoadTextFile(string path, Action<string> callback)
{
	var t = new Thread(() => {
		var text = File.LoadAllText(path);
		callback(text); // calls back here
	});
	t.Start();
}

One variant of this is to move the callback delegate from the parameter list to a field:

public class TextFileLoader
{
	private Action<string> callback;
 
	public TextFileLoader(Action<string> callback)
	{
		this.callback = callback;
	}
 
	public void Load(string path)
	{
		var t = new Thread(() => {
			var text = File.LoadAllText(path);
			callback(text); // calls back here
		});
		t.Start();
	}
}

We could also expose a property to allow changing the callback:

public class TextFileLoader
{
	public Action<string> Callback { get; set; }
 
	public void Load(string path)
	{
		var t = new Thread(() => {
			var text = File.LoadAllText(path);
			Callback(text); // calls back here
		});
		t.Start();
	}
}

More commonly we’d use an event:

public class TextFileLoader
{
	public event Action<string> OnLoaded;
 
	public void Load(string path)
	{
		var t = new Thread(() => {
			var text = File.LoadAllText(path);
			OnLoaded(text); // calls back here
		});
		t.Start();
	}
}

Or we could replace the delegate/event with an explicit kind of interface:

interface ITextFileHandler
{
	void Handle(string file);
}
 
void LoadTextFile(string path, ITextFileHandler handler)
{
	var t = new Thread(() => {
		var text = File.LoadAllText(path);
		handler.Handle(text); // calls back here
	});
	t.Start();
}

The handler interface could be stored as a field or property as we saw with the delegate above, but I’ll omit that for brevity.

Finally, we could use an iterator function to “call back” using yield return :

IEnumerable<float> MonitorWwwProgress(WWW www)
{
	while (www.isDone == false)
	{
		yield return www.progress; // calls back here
	}
}

That’s a lot of ways to “call back” in C#! Now let’s look at all the things that can go wrong when you call back. For starters, the callback itself can be null :

// for delegates or interfaces
LoadTextFile("/path/to/file", null);
 
// or...
var loader = new TextFileLoader(null);
loader.Load("/path/to/file");
 
// or...
var loader = new TextFileLoader();
loader.Callback = null; // or just don't set it- the default is null
loader.Load("/path/to/file");

All of these will throw an exception when you try to call the callback if you don’t remember to check for null first.

The next problem is that the callback could throw an exception:

LoadTextFile("/path/to/file", s => { throw new Exception(); });
 
// or...
foreach (var progress in MonitorWwwProgress(new WWW("http://test.com"))
{
	throw new Exception();
}

In either case—a null callback or an exception-throwing callback—there’s going to be an exception at the point where you call the callback. If you don’t catch this exception and your function is an instance function of a class then you may leave the object in a broken state. Even if you’re not an instance function then you could still cause some very strange behavior. Consider this simple code:

class NameFileReader
{
	public void Load(string path, Action<string> gotFirstName, Action<string> gotLastName)
	{
		var t = new Thread(() => {
			var lines = File.ReadAllLines(path);
			gotFirstName(lines[0]);
			gotLastName(lines[1]);
		});
		t.Start();
	}
}
 
var reader = new NameFileReader();
reader.Load(
	"/path/to/name/file",
	first => { throw new Exception(); },
	last => Debug.Log("last name is: " + last)
);

The above code throws an exception from its getFirstName callback. In the real world this would be accidental, but here we do it intentionally. This causes the Load function’s thread to abort before it can call the gotLastName callback. If you were waiting on this callback to do something important like show the next GUI screen then your game would get stuck!

The next problem happens if you’re calling back from an instance method. The callback could call another instance method of your class!

class TextFileLoader
{
	private string pathA;
	private string pathB;
	private Action<string> callback;
 
	public void LoadTwoFiles(string pathA, string pathB, Action<string> callback)
	{
		this.pathA = pathA;
		this.pathB = pathB;
		this.callback = callback;
		LoadAsync();
	}
 
	private void LoadAsync()
	{
		var tA = new Thread(() => {
			var text = File.LoadAllText(pathA);
			callback(text);
			var tB = new Thread(() => {
				var text = File.LoadAllText(pathB);
				callback(text);
			});
			tB.Start();
		});
		tA.Start();
	}
}
 
var loader = new TextFileLoader();
loader.Load(
	"/path/to/file/A",
	"/path/to/file/B",
	msg => loader.Load(
		"/path/to/file/C",
		"/path/to/file/D",
		m2 => {}
	)
);

Here’s what happens:

  1. Start loading files A and B
  2. When A loading finishes, call back
  3. Callback starts loading files C and D
  4. Overwrite fields pathA , pathB , and callback
  5. Start loading file D because pathB was overwritten
  6. Call back to second callback with contents of file D

This is a silly example, but the issue crops up all the time. If TextFileLoader has any mutable state then calls to its instance functions from the callback can modify that state. It’s really easy to get out of sync!

Finally, there’s a problem specific to iterator functions. When you yield return you’re calling back and suspending your function. Any state you have in that function’s local variables or in that class is captured and saved for later. If that state is significant then you’d better make sure to finish up with that iterator. For example, here’s some code that keeps a file handle open and potentially causes problems with file locking or running out of file handles:

IEnumerable<byte> ReadFileBytes(string path)
{
	using (var stream = File.OpenRead(path))
	{
		do
		{
			var cur = stream.ReadByte();
			if (cur == -1)
			{
				yield break;
			}
			yield return (byte)cur;
		}
		while (true);
	}
}
 
ReadFileBytes("/path/to/file").GetEnumerator().MoveNext();

Unlike other kinds of callbacks, iterators are manually resumed. The function doesn’t resume as soon as the callback returns, which gives the user an opportunity to never resume the function. The function’s local variables are held in limbo potentially forever. If it’s an instance function, the function will have a reference to this which means that the whole class instance will remain in memory, too. It’s really easy to accidentally hold open file handles or prevent garbage collection by simply failing to finish up with an iterator function.

That’s all for today about callbacks and their dangers. If you know of any more kinds of callbacks, problems they cause, or have any ideas for handling these problems then feel free to!





About List