In this exercise, we will learn how to create and initialize two threads and learn how they can affect one another with their priorities. There are two ways to create a thread in Zephyr, the first one is dynamically (at run-time) through k_thread_create()
and the other method, which is more frequently used, is statically (at compile time) by using the K_THREAD_DEFINE()
macro. This is the macro for defining and initializing a thread and plugging its data structures into the RTOS kernel, it has the API shown below:
1. In the GitHub repository for this course, open the base code for this exercise, found in l7/l7_e1
of whichever version directory you are using.
Creating and initializing threads
2. Define the stack size and scheduling priority of the two threads that we will use when defining them.
#define STACKSIZE 1024
#define THREAD0_PRIORITY 7
#define THREAD1_PRIORITY 7
CEven though the threads are simple in this exercise, we are setting a stack size of 1024. Stack sizes should always be a power of two (512, 1024, 2048, etc.).
In actual application development, you should choose the stack sizes more carefully, to avoid unnecessarily using the stack size. We do not need that here for a simple application.
We are giving the two threads the same priority. In this case, the actual number used isn’t important.
3. The thread entry functions for the two threads (thread0
and thread1
) are provided for you, but they contain no code. For now, let’s make them do something very simple which is just print a string in a while-loop.
Add the following printk()
statement inside the thread0
and thread1
entry function. Make sure to change the name of the second thread to thread1
.
printk("Hello, I am thread0\n");
CSince the threads have no dependency on each other and neither yield nor sleep, they will always be in the “Runnable” state competing for the CPU resource.
4. Now that we have defined the necessary parameters, we can define the two threads, thread0
and thread1,
using K_THREAD_DEFINE()
. Pass the thread’s name to be created, its stack size, thread entry function, optional arguments to pass to the thread up on starting(up to three), the thread’s priority, optional thread options, and finally, an optional scheduling delay.
For the optional arguments passed to the thread, we will pass NULL.
For the optional thread options, which can allow us to configure, for example, if the thread is to be treated as an essential thread K_ESSENTIAL
. It instructs the kernel to treat the termination or aborting of the thread as a fatal system error. By default, the thread is not considered to be an essential thread. We will not set an option in this exercise.
For the optional scheduling delay, we will also set it to zero, meaning the thread will be created and put in the ready state right away.
K_THREAD_DEFINE(thread0_id, STACKSIZE, thread0, NULL, NULL, NULL,
THREAD0_PRIORITY, 0, 0);
K_THREAD_DEFINE(thread1_id, STACKSIZE, thread1, NULL, NULL, NULL,
THREAD1_PRIORITY, 0, 0);
CThe name can be anything but is used as the thread ID, so name wisely.
5. Build the application and flash it on your development kit. Using a serial terminal you should see the below output:
*** Booting nRF Connect SDK 2.6.1-3758bcbfa5cd ***
Hello, I am thread0
Hello, I am thread0
Hello, I am thread0
Hello, I am thread0
Hello, I am thread0
Hello, I am thread0
Hello, I am thread0
Hello, I am thread0
Hello, I am thread0
Hello, I am thread0
Hello, I am thread0
Hello, I am thread0
Hello, I am thread0
Hello, I am thread0
Hello, I am thread0
Hello, I am thread0
TerminalNotice that we only see output from thread0
, indicating that thread1
never gets to run even though both threads were created with the same parameters and priority. The second thread is being starved, meaning it’s constantly being blocked since thread0
never yields, or waits (sleeps), or does any other event that triggers a rescheduling point.
Let’s see how we can fix this.
Thread yielding
To avoid starving thread1
, we will make thread0
voluntarily yield using k_yield()
.
k_yield()
causes the current thread to give away execution (yield) to another thread of the same or higher priority. The thread will be in the “Runnable” state, just pushed to the end of the list of “Runnable” threads. If there are no other threads in that list of the same or higher priority, the threads runs again immediately.
To give lower priority threads a chance to run, the current thread needs to be put to “Non-runnable”. This can be done using k_sleep()
, which we will see further on in this exercise.
A thread normally yields when it either has nothing else to do or wants to give other equal or higher priority threads a chance to run. There is usually some logic behind when a thread wants to call k_yield()
. To keep this exercise simple, let us make thread0
yield every time it completes the printk()
message.
6. Change the entry function of thread0
so it yields after running printk()
.
k_yield();
C7. Build the application and flash it on your development kit. Using a serial terminal you should now see the below output:
*** Booting nRF Connect SDK 2.6.1-3758bcbfa5cd ***
Hello, I am thread0
Hello, I am thread1
Hello, I am thread1
Hello, I am thread1
Hello, I am thread1
Hello, I am thread1
Hello, I am thread1
Hello, I am thread1
Hello, I am thread1
Hello, I am thread1
Hello, I am thread1
Hello, I am thread1
Hello, I am thread1
Hello, I am thread1
Hello, I am thread1
Hello, I am thread1
TerminalNotice that thread0
is now able to print one message and then yields voluntarily to equal or higher priority threads. Since there is another equal priority thread in the “Runnable” state, this thread is now made active by the scheduler and will get the CPU time. Since thread1
never yields it will run forever once it becomes active, starving thread0
.
8. Let’s make thread1
yield as well by adding k_yield()
after it prints its message.
k_yield();
C9. Build the application and flash it on your development kit. Using a serial terminal you should now see the below output:
*** Booting nRF Connect SDK 2.6.1-3758bcbfa5cd ***
Hello, I am thread0
Hello, I am thread1
Hello, I am thread0
Hello, I am thread1
Hello, I am thread0
Hello, I am thread1
Hello, I am thread0
Hello, I am thread1
Hello, I am thread0
Hello, I am thread1
Hello, I am thread0
Hello, I am thread1
Hello, I am thread0
Hello, I am thread1
TerminalAnd the events on the CPU look similar to the ones below
Since both threads now yield after printing a message, the scheduler comes into play between every thread to evaluate if there are any runnable threads in the queue. Since both threads have equal priority, the scheduler will always choose the other thread as the next running thread, resulting in the two threads alternating each time they’ve run.
The disadvantage of this is that yielding this often and thereby invoking the scheduler also takes up CPU time. The scheduler uses CPU time to do the book-keeping of the kernel resources every time k_yield()
is called which in turn costs power. A system with good architecture entails designing your threads so the scheduler uses a minimal amount of CPU time, i.e designing threads that have correct priorities and are reasonably considerate (yielding/sleeping/waiting) to other threads.
Thread sleeping
Since printing is a noncrucial task, it is acceptable for our threads to print less frequently. Therefore, a better option can be for the threads to sleep rather than yield. When sleeping, threads are put in the “Non-runnable” state and do very little processing. This is done by using k_sleep()
or some derivative, like k_msleep()
.
k_sleep()
expect a parameter of type k_timeout_t
which can be constructed using macros like K_MSEC()
. For simplicity, k_sleep()
has simpler-to-use derivatives like k_msleep()
and k_usleep()
that you can pass the time unit directly. The latter will be used extensively.
10. Let’s try this out by replacing the k_yield()
function in both threads with k_msleep(5)
, i.e a sleep duration of 5 ms. Now the threads should look like below:
void thread0(void)
{
while (1) {
printk("Hello, I am thread0\n");
k_msleep(5);
}
}
void thread1(void)
{
while (1) {
printk("Hello, I am thread1\n");
k_msleep(5);
}
}
C11. Build the application and flash it on your development kit. Using a serial terminal you should now see the below output:
*** Booting nRF Connect SDK 2.6.1-3758bcbfa5cd ***
Hello, I am thread0
Hello, I am thread1
Hello, I am thread0
Hello, I am thread1
Hello, I am thread0
Hello, I am thread1
Hello, I am thread0
Hello, I am thread1
Hello, I am thread0
Hello, I am thread1
Hello, I am thread0
Hello, I am thread1
Hello, I am thread0
Hello, I am thread1
TerminalAlthough the output looks identical to when the threads were using k_yield()
, there is a significant difference here. The threads are printing less frequently and thereby calling the scheduler less frequently. Take a look at the time sequence graph below and look at the idle period during which the system can switch to low power states.
As you can see, when both threads sleep for a certain amount of time and there are no other ready threads that can be made active by the scheduler, it makes the idle thread (which is one of the system threads) active.
In conclusion:
k_yield()
will change the thread state from “Running” to “Runnable”, which means that at the next rescheduling point, the thread that just yielded is still a candidate in the scheduler’s algorithm for making a thread active (“Running”). The overall result is that after the thread yields, there will be at least one item in the runnable thread queue for the scheduler to choose from at the next rescheduling point.k_sleep()
will change the thread state from “Running” to “Non-runnable” until the timeout has passed, and then change it to “Runnable”. This means that the thread will not be candidate in the scheduler’s algorithm until the timeout amount of time has passed. Hence thread sleeping is better choice for adding delays and not for yielding. The solution for this exercise can be found in the GitHub repository, in l7/l7_e1_sol
of whichever version directory you are using.