Brendan's Multi-tasking Tutorial: Difference between revisions

m
Bot: Replace deprecated source tag with syntaxhighlight
[unchecked revision][unchecked revision]
m (Fixed incorrect italic block)
m (Bot: Replace deprecated source tag with syntaxhighlight)
 
(2 intermediate revisions by 2 users not shown)
Line 30:
For example (NASM syntax, 32-bit 80x86, "cdecl" calling convention, single-CPU only/globals used for "per CPU" variables, no support for FPU/MMX/SSE/AVX, untested):
 
<sourcesyntaxhighlight lang="asm">
;C declaration:
; void switch_to_task(thread_control_block *next_thread);
Line 76:
 
ret ;Load next task's EIP from its kernel stack
</syntaxhighlight>
</source>
 
Next; write a function to create a new kernel task. This is a little bit like the code you already wrote for "initialise_multitasking()" (because it would allocate some memory for the structure used to keep track of the task's information and fill it in); except that you will need to allocate a new kernel stack for the new task and put values on the new kernel stack to match the values that your "switch_to_task(task)" function expects to pop off the stack after switching to the task. Don't forget that the values on the new kernel stack will include a "return EIP" (the address that the "switch_to_task(task)" function will return to), which should come from an input parameter of the "create_kernel_task()" function (e.g. like "create_kernel_task(void * startingEIP)" except probably with a function pointer instead of a void pointer if you can remember the correct syntax for that).
Line 113:
For time accounting, when certain things happen you want to subtract the counter's current value from the value it had last time (to determine how much time elapsed since last time); then add the time elapsed to the amount of time that the current task has consumed and update the time the counter was read last, a little bit like this:
 
<sourcesyntaxhighlight lang="c">
void update_time_used(void) {
current_count = read_counter();
Line 120:
current_task_TCB->time_used += elapsed;
}
</syntaxhighlight>
</source>
 
The first place this needs to be called is during task switches, to update the amount of time that the previously running task consumed before switching to the next task. For extra goodness "update_time_used()" can also be called when someone asks the kernel how much time a task has consumed, so that (e.g.) if a task has been running for 10 ms already then that 10 ms is included in the time a task has consumed.
Line 147:
Finally; the "schedule()" function needs to be changed so that it removes the task from the "ready to run" list before doing a task switch, making it something like this:
 
<sourcesyntaxhighlight lang="c">
void schedule(void) {
if( first_ready_to_run_task != NULL) {
Line 155:
}
}
</syntaxhighlight>
</source>
 
Note that if there's only one task you don't really want to do any task switches (because there's no other tasks to switch to), and in this case the task will be "running" and the list of "ready to run" tasks will be empty. The previous version of "schedule()" was a little less efficient (it would've done a task switch from one task to the same task).
Line 173:
The first option is probably more efficient; but we don't really care much about getting the highest performance possible at this stage, and because I want to make it look a little bit like a lock (to make it easier to support multiple CPUs later) I'm going to use the second option. It might look like this:
 
<sourcesyntaxhighlight lang="c">
int IRQ_disable_counter = 0;
 
Line 191:
#endif
}
</syntaxhighlight>
</source>
 
Once that's done, you need to add a comment to your "schedule()" function and to your "switch_to_task()" function saying that the caller is responsible for making sure the scheduler is locked before calling.
Line 197:
Then you need to comply with the new comment by locking and unlocking the scheduler at the right places. Fortunately you only have a few kernel tasks that call "schedule()" directly; so you only need to add locking/unlocking to them. The end result might look a little like this:
 
<sourcesyntaxhighlight lang="c">
void test_kernel_task(void) {
// unlock_scheduler();
Line 207:
}
}
</syntaxhighlight>
</source>
 
This can be a little confusing depending on your perspective. From the perspective of one task, it locks the scheduler, does a task switch, and then when it gets CPU time again it unlocks the scheduler; and this is fine (the scheduler is always unlocked after its locked). From the perspective of the CPU, one task locks the scheduler then the scheduler does a task switch and a completely different task unlocks the scheduler; and this is also fine (the scheduler is still unlocked after its locked).
Line 223:
Blocking a task is incredibly easy (mostly because we prepared for it in the last step by making sure a task in the "running" state is not on the scheduler's "ready to run" list). You only need to set the task's state and then find another task to run, which might look a little like this:
 
<sourcesyntaxhighlight lang="c">
void block_task(int reason) {
lock_scheduler();
Line 230:
unlock_scheduler();
}
</syntaxhighlight>
</source>
 
Unblocking a task is a little more involved. The first step is to decide if the task being unblocked should (or shouldn't) preempt the currently running task. For now (because we don't have any fancy task priorities or anything) we could never preempt, or we could only preempt if there was only one running task (because it's likely that if there was only one running task that task has been able to use lots of CPU time anyway). For fun (and because it helps to show how preemption would work) I'm going to go with the latter option.
Line 240:
The code might look something like this:
 
<sourcesyntaxhighlight lang="c">
void unblock_task(thread_control_block * task) {
lock_scheduler();
Line 256:
unlock_scheduler();
}
</syntaxhighlight>
</source>
 
The other thing we'll need is a reason to block a task. For this, we can add the ability for a task to voluntarily pause itself until some other task un-pauses it, which is something that might be useful later. To implement this we only need to define a new task state (PAUSED).
Line 273:
To fix the problem you need a function that uses absolute time instead of relative time (e.g. "sleep until time since boot is >= 123456"). This might look a little like this:
 
<sourcesyntaxhighlight lang="c">
delay = 250000000; // 250000000 nanoseconds is 250 milliseconds
next_update = get_time_since_boot();
Line 281:
nano_sleep_until(next_update);
}
</syntaxhighlight>
</source>
 
For code like this, the time between calls to "do_something()" still varies, but if its possible to call "do_something" often enough you can guarantee that these variations will average out and that "do_something()" will be called every 250 ms on average. Of course if there's a design flaw and "do_something()" takes too long (e.g. longer than 250 ms), or if there's so many other tasks consuming CPU time that this task can't keep up (which is a problem that can be fixed with task priorities that we don't have yet), then it isn't possible to call "do_something" often enough - if that happens, the "nano_sleep_until(next_update);" will do nothing (no delay at all) and "do_something()" will be called as often as possible, which is still the best behaviour anyone can expect in those conditions.
Line 287:
Fortunately, if you have "nano_sleep_until()" then you can use it to implement "nano_sleep()", like this:
 
<sourcesyntaxhighlight lang="c">
void nano_sleep(uint64_t nanoseconds) {
nano_sleep_until(get_time_since_boot() + nanoseconds);
}
</syntaxhighlight>
</source>
 
In a similar way, if you have either "nano_sleep_until()" or "nano_sleep()" you can use them to implement "sleep()" - you only need to multiply seconds by 1000000000 to get nanoseconds.
Line 320:
It might look a little like this:
 
<sourcesyntaxhighlight lang="c">
int postpone_task_switches_counter = 0;
int task_switches_postponed_flag = 0;
Line 347:
#endif
}
</syntaxhighlight>
</source>
 
The "schedule()" function only needs a few lines to check "postpone_task_switches_counter" and set "task_switches_postponed_flag", and that might end up a looking like this:
 
<sourcesyntaxhighlight lang="c">
void schedule(void) {
if(postpone_task_switches_counter != 0) {
Line 363:
}
}
</syntaxhighlight>
</source>
 
You'd need to make the same little change at the start of the "switch_to_task()" function, but that's "architecture specific assembly". For 32-bit 80x86 it might just a few instructions inserted at the start of the function (before all other instructions), like:
 
<sourcesyntaxhighlight lang="asm">
cmp dword [postpone_task_switches_counter],0
je .continue
Line 374:
 
.continue:
</syntaxhighlight>
</source>
 
Of course if you wanted to you could have a high level language wrapper that does this before calling the low level assembly function - either way is fine (I don't like wrappers much, but if you care about portability a wrapper would mean there's a little less code to change when porting).
Line 394:
For "nano_sleep_until()" you'd store the time the task is supposed to wake up somewhere, put the task on the unsorted list, then block the task. You'll need to add a "wake up time" field to the data structure you're using to keep track of tasks for this, and you'll need to define a new task state ("SLEEPING"). The code might look like this:
 
<sourcesyntaxhighlight lang="c">
thread_control_block * sleeping_task_list = NULL;
 
Line 422:
block_task(SLEEPING);
}
</syntaxhighlight>
</source>
 
Note that we're using the same "next" field in the data structure that the kernel uses to keep track of a task's information that the scheduler uses for its list of "ready to run" tasks. A task can't be ready to run and sleeping at the same time, so this is fine.
Line 428:
The code for the timer IRQ handler depends a little on which timer you're using. If you're using the PIT chip on 80x86 PCs, and the PIT chip has been set to a frequency of about 1000 Hz, then the timer IRQ handler might look like this:
 
<sourcesyntaxhighlight lang="c">
uint64_t time_since_boot = 0;
uint64_t time_between_ticks = 1000000; // 1000 Hz = 1 ms between ticks = 1000000 nanoseconds between ticks
Line 464:
unlock_stuff();
}
</syntaxhighlight>
</source>
 
Finally, test the code to make sure tasks block and unblock correctly when they call "sleep()" or "nano_sleep()" or "nano_sleep_until()". Please note that currently there's a minor problem with the scheduler - if all tasks are blocked waiting for something (either paused or sleeping) there won't be any tasks that the scheduler can give CPU time to so the scheduler will give CPU time to a task that is supposed to be blocked (which is bad!). For now, just make sure that at least one task is running.
Line 483:
The code might look like this:
 
<sourcesyntaxhighlight lang="c">
void kernel_idle_task(void) {
for(;;) {
Line 489:
}
}
</syntaxhighlight>
</source>
 
The next problem is that currently we're still using an awful cooperative round robin scheduler, which means that if the scheduler gives the idle task any CPU time it might hog all of the CPU time even after other tasks wake up. What we really need is a scheduler that isn't awful - e.g. a scheduler that understands task priorities, so that we can give the idle task the lowest possible priority and let all the other tasks (which will have higher priorities) preempt the idle task.
Line 497:
For this, you'll need a variable to keep track of which task is the idle task. Then you'd modify the "unblock_task()" code so that the idle task is always pre-empted, like this:
 
<sourcesyntaxhighlight lang="c">
void unblock_task(thread_control_block * task) {
lock_scheduler();
Line 514:
unlock_scheduler();
}
</syntaxhighlight>
</source>
 
The other thing you'd want to do is modify "schedule()" so that it tries to avoid giving the idle task CPU time when other task's are "ready to run":
 
<sourcesyntaxhighlight lang="c">
void schedule(void) {
if(postpone_task_switches_counter != 0) {
Line 544:
}
}
</syntaxhighlight>
</source>
 
Note that this is a relatively crude hack; but that's fine for now - later we're going to replace the scheduler with something less awful anyway.
Line 557:
The (optional) code for time accounting does need to be fixed though, and it'd be nice to keep track of how much time the CPU spend idle too, so that can be changed to something like this:
 
<sourcesyntaxhighlight lang="c">
uint64_t CPU_idle_time = 0;
 
Line 571:
}
}
</syntaxhighlight>
</source>
 
That only leaves the "schedule()" function itself, which could end up like this:
 
<sourcesyntaxhighlight lang="c">
void schedule(void) {
thread_control_block * task;
Line 623:
}
}
</syntaxhighlight>
</source>
 
==Step 11: Time Slices==
Line 637:
For the earlier example (using the PIT on 80x86 at a frequency of 1000 Hz) it might look like this:
 
<sourcesyntaxhighlight lang="c">
uint64_t time_slice_remaining = 0;
 
Line 686:
unlock_stuff();
}
</syntaxhighlight>
</source>
 
The other change that's needed is that the "time_slice_remaining" variable needs to be set when a task switch happens. This can be done in the low level task switch code (which is "architecture specific assembly").
Line 692:
If you added a wrapper earlier (when updating locking in Step 8), and if you implemented idle time using an idle task, then it might look like this:
 
<sourcesyntaxhighlight lang="c">
#define TIME_SLICE_LENGTH 50000000 // 50000000 nanoseconds is 50 ms
 
Line 708:
switch_to_task(task);
}
</syntaxhighlight>
</source>
 
If you added a wrapper earlier and implemented idle time without an idle task, then it might look like this:
 
<sourcesyntaxhighlight lang="c">
#define TIME_SLICE_LENGTH 50000000 // 50000000 nanoseconds is 50 ms
 
Line 733:
switch_to_task(task);
}
</syntaxhighlight>
</source>
 
If you're not using a wrapper, it shouldn't be too hard to convert the changes above into assembly and add them to your low-level task switch code (for both cases; it's just some comparisons, branches and moves).
Line 748:
The task termination might look like this:
 
<sourcesyntaxhighlight lang="c">
thread_control_block *terminated_task_list = NULL;
 
Line 776:
unlock_stuff();
}
</syntaxhighlight>
</source>
 
Note that we're using the same "next" field in the data structure that the kernel uses to keep track of a task's information that the scheduler uses for its list of "ready to run" tasks (which is used for sleeping tasks too!). A task can't be ready to run and terminated at the same time, and a task can't be sleeping and terminated at the same time, so this is fine.
Line 782:
Of course this won't work without a "cleaner" task. The cleaner task might look a little bit like this:
 
<sourcesyntaxhighlight lang="c">
void cleaner_task(void) {
thread_control_block *task;
Line 802:
kfree(task);
}
</syntaxhighlight>
</source>
 
==Step 13: Mutexes and Semaphores==
Line 814:
The first thing we'll want is the semaphore's structure itself, which mostly just needs a few fields for counts plus a linked list for waiting tasks. For the linked list of waiting tasks I want to make sure tasks are taken from the front and added to the back to ensure that one task can be very unlucky and end up waiting forever while other tasks are lucky and keep acquiring the semaphore, which is slightly more tricky with singly linked lists (you need a "last task" field and a "first task" field). The semaphore's structure can be something like this:
 
<sourcesyntaxhighlight lang="c">
typedef struct {
int max_count;
Line 821:
thread_control_block *last_waiting_task;
} SEMAPHORE;
</syntaxhighlight>
</source>
 
 
Next we'll want some functions to create a semaphore or a mutex, like this:
 
<sourcesyntaxhighlight lang="c">
SEMAPHORE *create_semaphore(int max_count) {
SEMAPHORE * semaphore;
Line 842:
return create_semaphore(1);
}
</syntaxhighlight>
</source>
 
With that done, we want some functions to acquire a semaphore or a mutex:
 
<sourcesyntaxhighlight lang="c">
void acquire_semaphore(SEMAPHORE * semaphore) {
lock_stuff();
Line 869:
acquire_semaphore();
}
</syntaxhighlight>
</source>
 
Lastly, we need to be able to release the semaphore or mutex:
 
<sourcesyntaxhighlight lang="c">
void release_semaphore(SEMAPHORE * semaphore) {
lock_stuff();
Line 894:
release_semaphore();
}
</syntaxhighlight>
</source>
 
 
Line 959:
 
The simplest solution is to always save and load FPU/MMX/SSE/AVX state during all task switches (and that would be a recommended starting point). There are also multiple more complex (and more efficient) ways of doing it; including always saving the previous task's FPU/MMX/SSE/AVX state during task switches if you know it was used but still postponing the work of load the next task's state until the task actually does need it; and including keeping track of "never used, sometimes used, always used" and pre-loading the FPU/MMX/SSE/AVX state when you know a task always uses it (or uses it often enough).
 
[[Category:Tutorials]]
[[Category:Multitasking]]