Multithreading

A 'thread' is a part of program code that can run independently and at the same time as other parts of the program (in some programing languages threads are called 'tasks'). Every program has at least one thread, the main thread. If the program 'spawns' other threads that run simultaneously with the main thread then that is known as 'multithreading'. For example, you could create a main thread that controls the motors, while a different thread can watch sensors or user input.

Multithreading is part of standard Python - it's not specific to EV3, so it's a topic you should have learnt about before beginning EV3 Python programming. This site gives only a shallow discussion of Python multithreading, but I do include a couple of videos at the bottom of this page.

EV3-G makes multithreading look easy (Lego calls it 'multitasking') but in Python it's not so simple. It's easy enough to create (spawn) additional threads - the problem is more in stopping them at the the right time.

In the first example below a thread called twenty_tones is started - it plays twenty tones while the main script waits for the touch sensor button to be 'bumped' (pressed and released) 5 times. The idea is that bumping five times will cause the tone-playing to be interrupted. But if you try running the script you will observe that in this case the playing of the tones is NOT interrupted if you bump the sensor 5 times. However, if the tones stop before the five bumps have been finished then program execution stops after the bumps (and the beep) have been completed, as you would expect. 

The key lines for setting up the thread are in bold. Notice that in the line

t = Thread(target=twenty_tones)

there are no parentheses after the thread name twenty_tones.

#!/usr/bin/env python3

from ev3dev2.sensor.lego import TouchSensor

from ev3dev2.sound import Sound

from time import sleep

from threading import Thread

ts = TouchSensor()

sound = Sound()

def twenty_tones():

    for j in range(0,20):           # Do twenty times.

        sound.play_tone(1000, 0.2)  # 1000Hz for 0.2s

        sleep(0.5)

t = Thread(target=twenty_tones)

t.start()

for i in range(0,5):       # Do five times, with i = 0, 1, 2, 3, 4.

    ts.wait_for_bump()

sound.beep()

In the above script the program ends only after both the tone sequence AND the button presses have been finished. You can pass one or more arguments to the thread in a tuple as shown below. Note that in the case of a single element tuple, the trailing comma is required, as we see in args=(1500,). Changes are highlighted in yellow.

#!/usr/bin/env python3

from ev3dev2.sensor.lego import TouchSensor

from ev3dev2.sound import Sound

from time import sleep

from threading import Thread

ts = TouchSensor()

sound = Sound()

def twenty_tones(frequency):

    for j in range(0,20):             # Do twenty times.

        # sound.play_tone(frequency, 0.2) #1500Hz for 0.2s

        sleep(0.5)

t = Thread(target=twenty_tones, args=(1500,))

# t.setDaemon(True)   # see notes below

t.start()

for i in range(0,5):       # Do five times, with i = 0, 1, 2, 3, 4.

    ts.wait_for_bump()

sound.beep()

In both the above examples we see that the script does not work as intended - bumping the sensor button 5 times does NOT interrupt the playing of the tones. So what is the solution? In fact in Python it is difficult or impossible to include something in the main script that would terminate the twenty_tones thread, and that's probably deliberate and a good thing because it could have messy consequences. (But see the note at the bottom of this page).

But it IS possible in python to mark (flag) a thread as a 'daemon' (background) thread, which is very useful because the entire Python program exits when only daemon threads are left. In other words, if you have a main thread and a daemon thread then as soon as the main thread exits the daemon thread will also exit. To flag a thread called 't' as a daemon thread do     t.setDaemon(True). So all the scripts above could be made to behave as desired by including this line just after the line    t = Thread(target=twenty_tones). If you un-comment the line highlighted in green in the above script you will see the script behaving correctly, with the daemon thread stopping as soon as the main thread stops.

An alternative but much less good solution (inspired by this video) is shown below. In this script the tones will play indefinitely until the touch sensor is bumped five times, after which the tones_forever thread terminates neatly. Pay attention to the lines in bold.

#!/usr/bin/env python3

from ev3dev2.sensor.lego import TouchSensor

from ev3dev2.sound import Sound

from time import sleep

from threading import Thread

ts = TouchSensor()

sound = Sound()

def tones_forever():

    # global loop # NOT NEEDED

    while loop:  # Same as while loop == True

        sound.play_tone(1000, 0.2)  # 1000Hz for 0.2s

        sleep(0.5)

loop = True

t = Thread(target=tones_forever)

t.start()

for i in range(0,5):

    ts.wait_for_bump()

sound.beep()

loop = False

# So that the loop in the tones_forever thread stops looping

In the above script the main part creates a variable called loop which is given the value True. In the tones_forever thread the loop repeats as long as the loop variable is equal to True. The last line of the main part of the script sets loop equal to False so that the loop in the tones_forever thread also stops running.

You may have seen similar scripts in which the variable in the thread function is made global so that it is the same variable that appears in the main block of code. Normally when a variable is created in a function it has 'local scope' meaning it exists only within the function and cannot be read or changed in the main block of code. If a variable with the same name appears in the main block of code then it's actually a different, independent variable. The variable can be made global in the function if we want that variable to have 'global scope' and to be the same variable as the one in the main block which has the same name. So why is not not necessary to make the loop variable global in the above script? The loop variable already existed when the function was called so in this case it does not have to be created within the function - the existing variable gets used. This is a very quick explanation - if you don't understand what global is about then do more research on sites like this one. It's good that we don't need to use global because it can lead to confusion, so professional coders try to avoid using global unless really necessary. We've already seen a solution in which we avoided the use of global by using a daemon thread and the video also hints at how they might handle the problem.

Note that it's also possible to do the above script 'backwards', with the 'thread' stopping a loop in the main part of the script, as shown below. The thread button_stop waits for the touch sensor to be bumped 5 times and once that has happened it sets the global variable loop to be False which causes the loop in the main part of the script to stop looping. But in this script the global keyword DOES have to be included! WHY?

#!/usr/bin/env python3

from ev3dev2.sensor.lego import TouchSensor

from ev3dev2.sound import Sound

from time import sleep

from threading import Thread

ts = TouchSensor()

sound = Sound()

def button_stop ():

    global loop # so that we can use the same variable 'loop'

    # that we created in the main script below

    for i in range(0,5):

        ts.wait_for_bump()

    sound.beep()

    loop = False # So that the loop in the main part stops looping

# Create a variable 'loop' which will be set to

# False when we want the loop below to stop looping.

loop = True

t = Thread(target=button_stop)

t.start()

while loop:  # Same as while loop == True

    sound.play_tone(1000, 0.2)  #1000Hz for 0.2s

    sleep(0.5)

Why use a touch sensor when we can instead make use of the buttons that are built into the EV3 brick? The script below has a main part which uses a loop to make the robot advance only when the light level is above 15 and a thread which waits for any EV3 button to be pressed and then beeps and causes the move_when_bright loop to stop looping. If you play with this script you may be surprised to note that even though the beep happens as soon as you press any button it may take many more seconds before the script actually stops. This is logical if you think about it - we are not issuing an instruction to instantly terminate the main part of the code, only to cause the loop not to repeat, so the program has to reach the bottom of the loop before the program ends and in this case, according to how the light level changes, that could take many seconds. To avoid that problem you could change 

while cl.ambient_light_intensity<=15:

into

while cl.ambient_light_intensity<=15 and loop:

and change

while cl.ambient_light_intensity>15:

into

while cl.ambient_light_intensity>15 and loop:

so that the script waits for a change in the light level only when 'loop' is True. Note that 

while cl.ambient_light_intensity<=15 and loop:

is the same as 

while (cl.ambient_light_intensity<=15) and (loop==True):


#!/usr/bin/env python3

from ev3dev2.motor import OUTPUT_B, OUTPUT_C, MoveSteering

from ev3dev2.sensor.lego import ColorSensor

from ev3dev2.button import Button

from ev3dev2.sound import Sound

from threading import Thread

from time import sleep

steer_pair = MoveSteering(OUTPUT_B, OUTPUT_C)

cl = ColorSensor()

btn = Button()

sound = Sound()

def button_stop():

    global loop # so that we can use the same variable 'loop'

    # that we created in the main script below

    while btn.any()==False:  # while no button is pressed

        sleep(0.01)  # do nothing other than wait

    loop = False  # Set 'loop' to False to cause loop below to stop.

    sound.beep()

loop = True

t = Thread(target=button_stop)

t.start()

while loop:  # Loop until 'loop' is set to False by button_stop

    # Wait for the ambient light level to rise above 15

    # then start the robot.

    while cl.ambient_light_intensity<=15:

        sleep(0.1)  # Do nothing while we're waiting.

    

    steer_pair.on(steering=0, speed=50)

    

    # Wait for the ambient light level to be less than or equal to 15,

    # then stop the robot.

    while cl.ambient_light_intensity>15:

        sleep(0.1)  # Do nothing while we're waiting.

    

    steer_pair.off()

Since Python threads are cumbersome, it's always worth considering whether they can be avoided. The script below has the same functionality as the one above, but avoids the use of multithreading.

The script below makes the robot advance when the ambient light is bright. It can be stopped by pressing any EV3 button for at least 0.1 seconds and this is made possible without the use of multithreading. Instead, the code checks for a button press whenever it checks the light level and if a button press is detected then the sleep commands are not run and the main loop quickly reaches the end. The main loop does not repeat because it can repeat only when no buttons are pressed.

#!/usr/bin/env python3

from ev3dev2.motor import OUTPUT_B, OUTPUT_C, MoveSteering

from ev3dev2.sensor.lego import ColorSensor

from ev3dev2.button import Button

from ev3dev2.sound import Sound

from time import sleep

steer_pair = MoveSteering(OUTPUT_B, OUTPUT_C)

cl = ColorSensor()

btn = Button()

sound = Sound()

while btn.any()==False:  # Loop while no button is pressed.

    # Wait for the ambient light level to rise above 15

    # then start the robot.

    while cl.ambient_light_intensity<=15 and btn.any()==False:

        sleep(0.1)

    

    steer_pair.on(steering=0, speed=50)

    

    # Wait for the ambient light level to be less than or equal to 15,

    # then stop the robot.

    while cl.ambient_light_intensity>15 and btn.any()==False:

        sleep(0.1)

    

    steer_pair.off()

sound.beep()

Can the above script be simplified even more? Yes, those two while loops can be replaced with an if...else structure. In the script below, unlike the ones above, even if the light is unchanging then the motor pair will be receiving instructions many times per second. Is that acceptable or might that damage the motors in some way? It's acceptable!

#!/usr/bin/env python3

from ev3dev2.motor import OUTPUT_B, OUTPUT_C, MoveSteering

from ev3dev2.sensor.lego import ColorSensor

from ev3dev2.button import Button

from ev3dev2.sound import Sound

from time import sleep

steer_pair = MoveSteering(OUTPUT_B, OUTPUT_C)

cl = ColorSensor()

btn = Button()

sound = Sound()

while btn.any()==False:  # Loop while no button is pressed.

    if cl.ambient_light_intensity>15: # If the light is bright

        steer_pair.on(steering=0, speed=50)

    else:

        steer_pair.off()

    sleep(0.1)

steer_pair.off()

sound.beep()

 Generally speaking, multi-threading is quite a complex topic. To really understand multithreading, some extra study is recommended. To fully master python multithreading you will need to understand the concepts of locks, queues, daemons and join(), not all explained on this site.

Below is the simplest video (11 mins) I could find on python threading. Many scripts won't need the join() function but this one does. join() makes the calling thread wait until the named thread terminates. join() is usually placed in the main thread, as here. Without the join functions the main thread of the script in this video would finish almost instantly, printing 'I am done with all my work' long before the other two scripts terminate. The code can be downloaded HERE, and you should note that the downloadable code uses a sleep value of 1s as opposed to the video which uses 0.2s.

This 21 minute video below also helped me:

HERE is the official documentation for the Python threading module.

HERE is a nice introduction to Python 3 threading, but it is not EV3-specific.

HERE is an article which says that multi-threading in Python is difficult and that Python experts recommend "Do not use multiple threads. Use multiple processes." Running multiple processes is outside the scope of this site but see HERE and note that although the page refers to threads it is really about processes.

A final note: I said above that in Python you cannot give an instruction on one thread for another thread to be terminated. But it's worth noting that RobotC has a stopTask() function which I believe can be used to terminate a thread, and EV3-G has a useful Loop Interrupt block which can sometimes be used to cause a thread to terminate...