In my previous post, I gave a simple introduction to concurrency and asynchrony, using a cinematic scripting language as an example. We saw how the introduction of two key tools – an object representing work, called a Future, and a way for our code to wait for work to complete – allowed us to replace the use of threads (along with all the complication they implied) in our cinematic scripts, and keep the code short and understandable.
In this post, we’ll see how the four concepts introduced in the first post – concurrency, parallelism, synchronization, and asynchrony – apply to another problem.
In almost every game, loading your assets requires some time investment. You’ve got textures, models, sound, levels, etc – and in more complex games, all sorts of secondary assets enter the picture, like scripts, configuration files, and animations. For simple games this is addressed with the addition of a loading screen; you toss that thing up and load all the assets you need in one go (hopefully you don’t forget any) and then go about your merry way. But in general, players don’t enjoy load screens, so we like to strive to avoid them, or at least keep them short. In modern games, assets are often loaded in the background as the game runs, with great effort expended towards figuring out when to load a given asset and when it is no longer needed, to keep the game running smoothly without pauses.
Looking carefully at the problem of asset loading reveals that it requires all four of our concepts:
- Asset loading should be Concurrent, because we will often need multiple new assets at once and we do not want to stop the game itself while assets are loaded.
- Asset loading should be Parallel, because the loading of a texture should not prevent the loading of a sound effect from occurring at the same time, and more importantly if neither of these assets are required by the game at this very moment, the game should be able to continue.
- Asset loading should be Synchronized with the state of the game, so that an asset is always loaded before the game attempts to use it – otherwise, partially-loaded assets could be used and the game will crash or look wrong.
- And finally, asset loading should be Asynchronous, because if it isn’t, we’re forced to throw up a load screen and make the player wait.
For our example, let’s say that we’re loading a level in a very simple 2D game. The level lives in a map file, and the map file also depends on the contents of a few other assets – a tileset containing graphical tiles we used to construct the map, some individual creatures that inhabit the map (with their own textures and sound effects), and a piece of background music. Based on what we know, we can say this:
- The level depends on its tilesets, creatures and background music.
- The creatures depend on their textures and sound effects.
- The tilesets do not depend on anything.
- The background music does not depend on anything. Furthermore, our sound library might be able to stream it for us, so once playback has begun, we no longer need to wait.
- If possible, we want to use some of these assets before they are finished loading: We can fade the background music in while the level loads, and we could show the level before it’s finished as long as all on-screen content has loaded.
Let’s begin tackling the challenge of loading this level, with the most obvious solution:
class Level { // ... Level (string filename) { using (var f = File.OpenRead(filename)) { this.Tilesets = ReadTilesets(f); foreach (var tileset in this.Tilesets) tileset.Texture = ReadTexture(tileset.TextureName); this.Tiles = ReadTiles(f, this.Tilesets); this.Entities = ReadEntities(f); foreach (var entity in this.Entities) { entity.Texture = ReadTexture(entity.TextureName); foreach (var soundName in entity.SoundNames) entity.Sounds[soundName] = ReadSound(soundName); } var backgroundMusicName = ReadString(f); this.BackgroundMusic = ReadSound(backgroundMusicName); } } // ...
This isn’t an awful solution, but it’s painfully sequential: First, we read the tileset information from the file. Then we read in the textures for each of the tilesets. And so on.
Let’s begin by turning this into a coroutine, using Futures. It’s pretty straightforward, because C#’s runtime library gives you a way to do asynchronous IO, so we’ll assume the presence of some simple wrapper routines to give us Futures for things like file reads. We’ll ignore some language nits for now, like that constructors can’t be coroutines…
using (var file = File.OpenRead(filename)) { var fTilesets = ReadTilesets(file); yield return fTilesets; this.Tilesets = fTilesets.Result; foreach (var tileset in this.Tilesets) { var fTexture = ReadTexture(tileset.TextureName); yield return fTexture; tileset.Texture = fTexture.Result; } var fTiles = ReadTiles(file); yield return fTiles; this.Tiles = fTiles.Result; var fEntities = ReadEntities(file); yield return fEntities; this.Entities = fEntities.Result; foreach (var entity in this.Entities) { var fTexture = ReadTexture(entity.TextureName); yield return fTexture; entity.Texture = fTexture.Result; foreach (var soundName in entity.SoundNames) { var fSound = ReadSound(soundName); yield return fSound; entity.Sounds[soundName] = fSound.Result; } } var fBackgroundMusicName = ReadString(file); yield return fBackgroundMusicName; var fBackgroundMusic = ReadSound(fBackgroundMusicName.Result); yield return fBackgroundMusic; this.BackgroundMusic = fBackgroundMusic.Result; }
This is much bigger, and there’s a lot of noise – all those explicit yield returns, and the need to access .Result. Don’t worry too much; even in a rigid language like C#, you can eliminate a lot of that. Let’s take note of a couple interesting ways in which we can now improve on this:
- Only some of the operations we perform actually depend on file. The others that don’t can, assuming they don’t depend on global state, be run in any order.
- Furthermore, each of the distinct object types we load does not depend on the other objects: Entities do not interact with tiles or tilesets, and the background music doesn’t interact with anything.
Based on these two observations, we can split this routine up into multiple helper Tasks, enabling further improvements. For the examples that follow I’ll be using some helper routines to cut down on the noise, too.
Task LoadLevel (string filename) { using (var file = File.OpenRead(filename)) { // We need to read sequentially from the file, so we must kick off the // reads in the correct order. But since they produce futures, we don't // have to *wait* for them in that same order! var fTilesets = ReadTilesets(file); var fTiles = ReadTiles(file); var fEntities = ReadEntities(file); var fBackgroundMusicName = ReadString(file); // Kick off three helpers to finish loading data, and then wait // for those three helpers, plus the tiles. yield return new WaitForAll( FinishLoadingTilesets( fTilesets, (tileset) => this.Tileset = tileset ), FinishLoadingEntities( fEntities, (entities) => this.Entities = entities ) FinishLoadingMusic( fBackgroundMusicName, (music) => this.BackgroundMusic = music ), fTiles ); // We didn't start a helper to take care of waiting for the tiles. // They're done now, so we can assign them. this.Tiles = fTiles.Result; // We can't close the file until everything depending on it is done, // so we keep the 'using' block open until here. } } Task FinishLoadingTilesets (Future<TilesetCollection> fTilesets, Action<TilesetCollection> doneLoading) { yield return fTilesets; var tilesets = fTilesets.Result; foreach (var tileset in tilesets) { var fTexture = ReadTexture(tileset.TextureName); yield return fTexture; tileset.Texture = fTexture.Result; } doneLoading(tileset); } Task FinishLoadingEntities (Future<EntityCollection> fEntities, Action<EntityCollection> doneLoading) { yield return fEntities; var entities = fEntities.Result; foreach (var entity in entities) { var fTexture = ReadTexture(entity.TextureName); yield return fTexture; entity.Texture = fTexture.Result; foreach (var soundName in entity.SoundNames) { var fSound = ReadSound(soundName); yield return fSound; entity.Sounds[soundName] = fSound.Result; } } doneLoading(entities); } Task FinishLoadingMusic (Future<string> fFilename, Action<Sound> doneLoading) { yield return fFilename; var fBackgroundMusic = ReadSound(fFilename.Result); yield return fBackgroundMusic; doneLoading(fBackgroundMusic.Result); }
In this new version, we’re kicking off all the file reads right at the start. Since we start them in the correct order, as long as our file implementation is written correctly, they will read from the right positions in the file, and their Futures will become completed in that order, preserving the behavior we want, while allowing us to continue.
Similarly, we kick off helper tasks to finish loading each of those pieces of data, by handing them the future for the file read along with a callback to invoke when they’ve finished their work. The callback stores their finished data into its correct place. Once we’ve kicked off all the helper tasks, we wait for them all to complete. The order in which they complete doesn’t matter to us; we just want to know that everything’s finished. Once all the work is done, we can clean up.
By simplifying the main level loading function, what we’ve done here is encapsulate each distinct piece of work into something we can wait for – a Future – and by doing that, it’s become easy for us to start a lot of work and wait for it all to finish, instead of explicitly doing things in a specific order. Thanks to this new structure, many clever optimizations are possible. Here’s one example:
Task FinishLoadingEntities (Future<EntityCollection> fEntities, Action<EntityCollection> doneLoading) { yield return fEntities; var entities = fEntities.Result; var fTextures = ReadTextures( from entity in entities select entity.TextureName ); var fSounds = ReadSounds( from entity in entities from soundName in entity.SoundNames select soundName ); yield return Future.WaitForAll( fTextures, fSounds ); var textures = fTextures.Result; var sounds = fSounds.Result; foreach (var entity in entities) { entity.Texture = textures[entity.TextureName]; foreach (var soundName in entity.SoundNames) entity.Sounds[soundName] = sounds[soundName]; } doneLoading(entities); }
The fact that we no longer explicitly specify the order of work has allowed us to turn a series of individual asset loads into a pair of load operations that run on multiple files at once. Thanks to this improvement, we can now do all sorts of clever things in our asset loader – loading files ordered by their position on disc to avoid seek times, loading multiple files in parallel using multiple threads, etc – without any changes to this helper function.
If the places where your code waits for work are put there explicitly, you’re free to move them around. What we’ve done here is not only move those waits around, but combined them in order to make our code easier to optimize.
So far, we don’t have everything we want out of our asset loader, but we’ve gone from a strictly sequential loader to a loader that can easily take advantage of multiple cores and load levels in the background while the game is running. Not a bad start!
I’ll continue with this example in my next post, but for now, let’s stop for a moment to discuss Future, and some of its traits that make this possible:
- A future represents a single value of a known type that may be available in the future.
This trait makes it possible for us to do things like silently queue up work in the background – the actual work is not required to begin when the Future is created.
- If the operation producing the value fails, that failure is captured within the future, and anyone who accesses the future to retrieve the value is given the failure instead.
This trait ensures that any unhandled exceptions or unexpected failures within asynchronous work are not lost. Knowing this, we can limit our writing of error handlers to a few specific tasks, allowing child tasks to fail silently and propagate their errors up to the parent task that started them. When the parent task sees the error, it can respond as it sees fit – retry the operation, notify the user, etc.
- It is possible to ask a future to notify you when work is completed, instead of having to sit and wait for the work to complete.
This makes it possible for us to perform asynchronous work without being concerned about the nature of the work – we don’t need to use threads to wait for work; we can simply register a callback to be invoked when work is finished. The Scheduler in our examples uses these callbacks to ensure that a Task resumes once the Future it is waiting for has a value.
- Once a future contains a value or a failure, its contents will never change. If a future does not contain a value or a failure, a temporary failure will be returned when you access it.
This makes it possible for us to ignore a large number of potential race conditions and synchronization issues. As long as the contents of the object you place within a Future are not modified, you need not protect the object with a lock or any other synchronization mechanism; you either have a completely valid object, or no object.
These traits are relatively simple to provide – a simple Future class can be implemented in a few dozen lines of code in all the major languages I can think of – and they enable you to combine Futures to solve a variety of complex problems without spending much time thinking about threads or synchronization.