Godot is a Concurrent Runtime
And GDScript is a concurrent programming language.
GDScript is a bit janky of a language. It does have warts, lots of them; especially GDScript 1.0. However, it does have two things I think go well together: concurrency and signals.
In most concurrent programming languages, there is a mechanism for spinning code off into new task. Go has it's go keyword. Crystal has it's swap do ... end macro/sytnax. Erlang and Elixer have their spawn function. In each of these langauges, concurrency is first class and baked into the language's fundimental concepts.
Meanwhile, GDScript has no mechanism for directly spawning a new coroutine. Instead, it utilizes the await keyword. However, no function in GDScript needs to be explicitly marked async. At any point, a function is free to await on a Signal or another concurrent function. The currently running function is then suspended and control returns to the caller. That function is then resumed when an event is recieve or a coroutine has finally finished.
Using concurrency in GDScript
Signals are obversable events. These events can optionally send values back to the supsended function. This provides a rather ergonomic approach to asychrnous code. Additionally, since it is woven into GDScript's core, it is less combersome experience than other async/await languages such as JavaScript or C#.
A simple example of concurrency in GDScript is waiting for a button press:
func _ready():
print("Waiting for button press!")
await $MyButton.pressed
print("Button has been pressed!")
Another example is waiting on the next frame:
func change_some_stuff(): ... change some state stuff ... # Wait for the next frame so changes take effect await get_tree().process_frame ... do the rest of the work ...
This pattern is actually pretty useful and somewhat common. There are various spots in Godot where changes to the state of a node only happen on the next frame. This is usually GUI related (e.g a relayout).
Signals aren't limited to what is built into core nodes. GDScript allows the user to freely define their own signals. For example a dialogue system might have something like:
signal dialogue_shown signal option_picked(option) func start_dialogue(text): show_message(text) await dialogue_finished show_options() var option_picked = await option_picked ...
When working with games, this provides a extremely convient way to handle various events in your world. While great for that perpose, Signals and await can come together to allow for highly concurrent programming. For examples, signals behave as a first-in-first-out queue for coroutines:
signal queue_notified
var id = 0
func wait_in_queue(i):
await queue_notified
id = id + 1
print("I am coroutine: (", i, ", ", id, ))
func _ready():
for i in range(10):
wait_in_queue(i)
queue_notified.emit()
output: I am coroutine: (0, 1) I am coroutine: (1, 2) I am coroutine: (2, 3) I am coroutine: (3, 4) I am coroutine: (4, 5) I am coroutine: (5, 6) I am coroutine: (6, 7) I am coroutine: (7, 8) I am coroutine: (8, 9) I am coroutine: (9, 10)
In the above example, _ready queues up ten coroutines on the queue_notified signal. When queue_notified.emit() is evaulated, each coroutine resumes in a FIFO order. This means you can use signal to deterministically coordinate coroutines. From here, you can start building up more advance concurrent structures.
Herding Cats
With all concurrent programming, avoiding data-races will be an issue. One way this pops up in GDScript is when concurrent function calls are possibly re-entrant. A coroutine A running inside some object X could call a method that, through a series of other calls, re-enters object X starting a new coroutine B. Coroutine B could then change the state of object X. This change could then invalidate the state couroutine A was expecting.
Unfortunately, Godot only provides kernel-level mutices. Using a Mutex object in Godot will block all coroutines running on the current thread. Additionally, mutices in Godot are non-reentrant. This means if two coroutines on the same thread aquire the same lock without releasing it, the program dead locks. Luckily, signals and await provide everything you need to work around this:
class_name RWComutex
signal _unlocked()
var _locked = false
var _readers = 0
func lock():
while _locked or _readers > 0:
_locked = true
await _unlocked
_locked = true
func unlock():
_locked = false
_unlocked.emit()
func read_lock():
while _locked: await _unlocked
_readers += 1
func read_unlock():
_readers -= 1
if _readers == 0: _unlocked.emit()
The above code implements a many-readers-one-writer lock. As long as _locked is false, any number of readers may pass through it. When _locked is true, then all coroutines entering it will be blocked. Writers additionally block until all readers have left the protected region. The signal, _unlocked, acts as a queue for each coroutine to try an aquire the lock.
Upwards and Onwards
Other forms of concurrent communication can be emulated using Signals and await. For example, the following code implements a rudimentary Channel object:
extends Node
class Channel:
var ticket_queue = []
var value_queue = []
class Ticket:
signal picked(value)
func send(value):
if len(ticket_queue) == 0:
value_queue.push_back(value)
else:
ticket_queue.pop_front().picked.emit(value)
func recieve():
if len(ticket_queue) > 0 or len(value_queue) == 0:
var ticket = Ticket.new()
ticket_queue.push_back(ticket)
return await ticket.picked
else:
return value_queue.pop_front()
This can then be used to make a rough translation of an example from the Go playground:
func sum(id, xs, channel):
var sum = 0
var max_iters = 3
var iters = 0
for x in xs:
printt(id, "working")
iters += 1
sum += x
if iters == max_iters:
iters = 0
await get_tree().process_frame
channel.send(sum)
func _ready():
var c = Channel.new()
sum(0, [1, -2, 5, 8, -3, -4], c)
sum(1, [3, -1, 0, 5, 7, 9], c)
var x = await c.recieve()
var y = await c.recieve()
printt(x, y, x + y)
output:
0 working
0 working
0 working
1 working
1 working
1 working
0 working
0 working
0 working
1 working
1 working
1 working
5 23 28
Since GDScript is co-operatively scheduled, await is used to allow the system to continue onto other task. It's usage in is mostly for show.
This is just some of what's possible in GDScript. I didn't even cover the fact Signals are a first class object in Godot.
Tho, the language does have many many footguns. For example many built in functions are async-unsafe. The map function on arrays can leak GDScriptFunctionState objects: a data type that is supposed to be hidden. Godot 3 and Godot 2 have nasty interactions with deleting nodes and pending coroutines. Some
of which will make you give up on concurrency.
Closing
Signals in GDScript allow code to await on any event the programmer could want. Additionally, they provide a foundation strong enough to build other concurrent techniques atop. The concept of signals as observable asychrnous events should be further explored outside of Godot's DSL. GDScript is a concurrent programming language. Godot is a concurrent runtime. They might not be the best, but they certainly have merit.