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.