Tasks in Toit#
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.
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 LED1 ::= gpio.Pin.out 17 // The red LED is connected to pin 17. LED2 ::= gpio.Pin.out 18 // The green LED is connected to pin 18. main: // Note the double `::` on the next two lines. task:: my_task_1 // Start a task that runs the my_task_1 function. task:: my_task_2 // Start a second tast that runs my_task_2. my_task_1: while true: sleep --ms=500 LED1.set 1 sleep --ms=500 LED2.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).
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 ADC1 ::= gpio.Adc (gpio.Pin.in 17) // The analog input is connected to pin 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 mv := ADC1.get status.update mv /// Prints the current status every 123ms. my_task_2: while true: sleep --ms=123 print "Voltage is $(status.mv)mV, 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
missing_mv are not updated at the exact same moment.
The printing thread might read values of
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
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
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.
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 (defined elsewhere) to compute some value. result := do_other_processing latch.set result // This unblocks the other task, if it is already waiting on latch.get. consumer_task_code: // Call some do_processing function (defined elsewhere) to compute some value. my_result := do_processing other_result := latch.get // Blocks until the other task is done collecting a result. print "Result was $(my_result + other_result)"
Note that 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 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.
The Semaphore is a well
object. The counter is incremented
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.
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.
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