Tasks

Introduction

Some languages, like Java or C#, have multiple threads running at the same time. These are separate control flows that can manipulate each other's data. Because there can be different threads using the same objects, it is easy to make programming mistakes called race conditions.

Other languages, like JavaScript, have only one thread. This is very limiting in that your program constantly has to return to an event loop, which makes it much harder to program. Your code must be written in a non-blocking style with no long-running loops.

Tasks

Toit has a third option instead: Tasks (sometimes called fibers in other languages). Tasks have independent control flow but do not run simultaneously. An example:

import gpio

// The red LED is connected to pin 17.
LED1 ::= gpio.Pin.out 17
// The green LED is connected to pin 18.
LED2 ::= gpio.Pin.out 18

main:
  // Note the double `::` on the next two lines.
  // Start a task that runs the my-task-1 function.
  task:: my-task-1
  // Start a second task that runs my-task-2.
  task:: my-task-2

my-task-1:
  while true:
    sleep --ms=500
    LED1.set 1
    sleep --ms=500
    LED1.set 0

my-task-2:
  while true:
    sleep --ms=123
    LED2.set 1
    sleep --ms=123
    LED2.set 0

In this program, there are two tasks started with task::. Each of them has an infinite loop (while true), but because they have independent control flow, they can both take turns to run. They run in the same heap, so they see the same objects (in theory task 1 could access the same LED as task 2, though it might be confusing).

In this example, we can see that tasks or threads simplify the programming considerably. One LED is flashing once per second, while the other is flashing every 246ms. To code this in one loop, we would have to write a program that switched the LEDs at the times 0, 123, 246, 369, 492, 500, 615ms etc. Alternatively to code this in an event-driven language like JavaScript, we would have to remove all the loops and instead use a state machine with scheduled callbacks to perform the LED switching and update variables that represent state (in this case to track whether the next callback should switch on or off).

Cooperative scheduling

Unlike threads, the tasks in Toit do not actually run at the same time as each other. The program only switches to a different task when the previous task stops to wait for something. One way to wait is with the sleep function, but there are other ways, for example waiting for a GPIO pin to change with pin.wait-for. This is both a limitation and a simplification for the programmer. On the negative side, a task can block all other tasks, by never waiting (also called yielding). For example:

main:
  task:: my-uncooperative-task
  task:: my-starved-task

my-uncooperative-task:
  M ::= 1_000_000
  M.repeat:
    x := it + 1
    M.repeat:
      y := it + 1
      M.repeat:
        z := it + 1
        if x*x*x + y*y*y == z*z*z:
          print """
            Fermat's last theorem disproved!  \
            $x^3 + $y^3 = $z^3"""
          return

my-starved-task:
  while true:
    sleep --ms=100
    print "If you see this, the task is running."

In this example, the first task is attempting to find a counterexample to Fermat's Last Theorem. This will take a long time, since no such counterexample exists. While it is calculating, it doesn't wait for anything (yield), and so no other tasks can run. As soon as the uncooperative task starts running it hogs the CPU and prevents other tasks from running, stopping 'my-starved-task' from emitting messages (if it ever did so). task will stop appearing.

The advantage of tasks in comparison with threads is that they take turns. To take an oversimplified example:

import gpio
import gpio.adc as gpio

// The analog input is connected to pin 17.
ADC1 ::= gpio.Adc (gpio.Pin.in 17)

class VoltageStatus:
  mv /int := 0
  missing-mv /int := 0
  goal /int := ?

  constructor .goal:

  update new-mv/int -> none:
    mv = new-mv
    missing-mv = goal - mv

status := VoltageStatus 554  // Aim for 554millivolts.

main:
  task:: my-task-1
  task:: my-task-2

/**
Updates the status object with the measured voltage
  every 100ms.
*/
my-task-1:
  while true:
    sleep --ms=100
    value := ADC1.get
    status.update (value * 1000).to-int

/// Prints the current status every 123ms.
my-task-2:
  while true:
    sleep --ms=123
    print "Voltage is $(status.mv)mV"
    print "We are $(status.missing-mv)mV from our target"

This code looks rather simple and would work OK in Toit, but if we had threads instead of tasks it would contain a race condition. The two fields of the status object, mv and missing-mv are not updated at the exact same moment. The printing thread might read values of mv and missing-mv that do not fit together because the other thread has updated one, but not the other.

In Toit we only switch tasks at yield points. In this case the yield points are sleep and print. There is no yield point in the middle of the update method, and there is also no yield point while building up the string to be printed, so the tasks do not see inconsistent versions of the status object.

This rather contrived example also illustrates that the two tasks are running on the same heap, and see the same instance of the status object. The two tasks are part of the same program even though they have independent call stacks and loops.

Synchronizing between tasks with Monitors

Sometimes you need to control when two tasks run, relative to each other. For this there are some useful classes in the import monitor library.

Latch

One of the simplest of the classes in import monitor is Latch that allows one task to wait until a value (object) has been provided by another task.

import monitor

// Create a Latch object.
latch := monitor.Latch

main:
  task:: producer-task-code
  task:: consumer-task-code

producer-task-code:
  // Call some do-other-processing function to compute some value.
  result := do-other-processing

  // This unblocks the other task, if it is already waiting
  // on latch.get.
  latch.set result

consumer-task-code:
  // Call some do-processing function to compute some value.
  my-result := do-processing
  // Blocks until the other task is done collecting a
  // result.
  other-result := latch.get
  print "Result was $(my-result + other-result)"

do-other-processing:
  // Compute some value...
  return 0

do-processing:
  // Compute some value...
  return 0

In this example we use the Latch class, but we don't inherit from it. In general, inheriting from classes from import monitor will cause unexpected deadlocks in your program and is not recommended.

Channel

Channel is like Latch, but supports multiple messages, sent from the producer to the consumer. The channel has a capacity, which controls how many messages (objects) can be buffered. If the producer tries send more than capacity unread messages in the channel, the producer blocks until the consumer task receives a message, freeing up capacity.

Semaphore

The Semaphore is a well known synchronization object. The counter is incremented with the up method, which cannot block (originally called V). The counter is decremented with the down method (originally called P) which blocks until it has a non-zero internal counter to decrement.

Mutex

The Mutex has a single method, do which takes a block of code to execute. Only one task can run code at a time. The mutex is not reentrant.

Mailbox

The Mailbox can send multiple messages between tasks. Like the latch (and unlike the Channel) it doesn't have the ability to buffer messages. The receiver (consumer) must acknowledge each message with a response, sent with the reply method.