Multithreading on the Amiga

by John Corigliano

<jcorig@strauss.udel.edu>

I'm sure everyone knows that the Amiga is a multiprocessing computer, but you might not be aware that it is not really a multithreading computer. On the Amiga, it is fairly easy to create many processes, each one running independently of one another, but it's much harder to create one process that is many threads running simultaneously. The difference between a process and a thread is that while a process is a completely unique entity, a thread is dependent on some parent from which it inherits all of its resources.

Threads are mostly widely used on multiprocessor machines (i.e., an Amiga with five PowerPC's in it :). A process's work load can be distributed among as many threads as there are CPU's. A good example is a fractal image generator: when it is time to render the fractal, the program can branch into x threads (where x = number of CPU's). Each thread would have its own part of the display to render. With each thread working simultaneously, the amount of time required to render the image is dramatically decreased.

Obviously there is no such thing as a multiprocessor Amiga. However, this does not mean that we cannot use threads to our advantage. Threads can be used to make our programs more efficient and easier to mantain. Here are a few examples:

The Amiga OS contains a few different functions to create processes and tasks: CreateProc(), CreateNewProc(), AddTask(), etc (CreateTask() is not part of the OS but is an amiga.lib function). The problem with these functions, as far as multithreading goes, is that the task or process created has no association with the process that started it - it has its own stack, global data, program counter, etc. Even if the process that created the new process were to terminate, the new process would still be valid.

There are many situations where this is desired, but if we want to create a multithreaded program we are left out in the cold. What do we do if we want to create a thread that inherits all of its parent's resources? With only the OS, there's not much we can do. So we must turn to the compiler makers and the geta4() function.. This function (replaced by the __saveds keyword in SAS/C) can be used to create a new process that has access to the parent process' global data. This is exactly what we need to create threads!

To understand why we need geta4() let me attempt to explain how compilers manage global data. I'm sure you all know that a variable is usually just a memory address (it could also be a cpu register). So when you declare:

int x;

You are basically telling the compiler to set aside sizeof(int) bytes in memory and that you will be referencing that memory by the name "x". The compiler has no way of knowing what x's location will be when the program is actually run, so it cannot use absolute addressing to access "x". Instead, the compiler adds some code to your program that is executed before main(). Part of what this code does is allocate a block of memory that will contain all of your program's global data (or does the OS do this?). So, when the compiler builds your code, it does not create an absolute memory address for "x", but rather calculates an offset into this block of memory that will hold the value of "x".

Thus, when the program is run, and the line

x = 1;

is executed, the code gets the absolute address of the block of memory that was designated at program init to be the global data vector, and adds whatever offset is used for "x" and puts the value "1" into that memory location.

Let's assume that when the program was compiled, the compiler determined that "x" will be 0x08 bytes into the global data vector. Then the program is run and the init code allocates a chunk of memory at absolute location 0x01A567C0 to be used as the global vector. When the program needs to access "x" it will just need to go to address <0x01A567C0 + 0x08> to find x. So the assembly language code for "x = x + 1" might be:

; Prolog code has already put address of global vector in reg a4
move.l    0008(A4),D0   ; Move the value at (A4)+8 into register D0
addq      #1,D0         ; Add 1 to the value in D0
move.l    D0,0008(A4)   ; Move that value back into (A4)+8

The reason the function is called geta4() is because the cpu register a4 is traditionally used to hold the absolute address of the global vector. Normally, when the compiler codes a function call, it adds a little bit of extra code that ensures that register a4 does indeed point to the global vector. However, there are some situations where a4 does not contain that address. This is the case when a function is entered either from a hook callback or a function like CreateNewProc(). So we need geta4 for our threads which get started by CreateNewProc().

The code I have written relies on some features of the SAS/C developement package. I am not sure if other compilers support these:

Using The Code

To create a thread, all your program needs to supply is a name, stack size, entry point, and priority. All the work of creating, launching and terminating the thread is done by the supplied code. The code uses the CreateNewProcTags() function to create a process. The entry point of the new process is declared with the __saveds keyword so that it has complete access to the parent's data.

For example, suppose you have a function:

VOID MyNewThread(THREAD *me, APTR param);

that you want to make the entry point of a new thread. All you need to do is call my T_CreateThread() function with a name for your new thread, a stack size (4096 is usually sufficient), pass MyNewThread as the third argument, and supply a priority for the thread:

THREAD *my_thread = T_CreateThread("My Thread", 4096, MyNewThread, 0);

This will not start the thread! It just allocates the memory required for it and initializes the data. To start the thread going, use the T_Run() function:

if (!T_Run(my_thread, NULL)) ErrorMessage("Thread did not run!");

At this point, the thread should be up and running - unless T_Run() fails for some reason. At any point after the the thread is created you can check its status, which will be one of the following: The thread can terminate in one of two ways: When the parent process wants to tell the thread to shut down, it can use the T_Terminate() function. This function sets a signal that belongs to the thread. When the thread sees that this signal has been set, it should self-terminate. If the parent wants to wait until the thread is finished terminating, it can call the T_WaitForThread() function. This does not return until the thread is completely dead.

Lastly, T_KillThread() needs to be called to free all the resources associated with the thread. T_KillThread() calls T_TerminateAndWait() so you can skip the call to T_TerminateAndWait() if you want to kill the thread and wait for it in one call.

The thread can use the T_ShouldQuit() function to periodically check if it should shut down. This function checks to see if the signal to quit has been set. If the thread needs to Wait() on some event - like a window IDCMP port - it can use the T_WaitForEvents() function. T_WaitForEvents() acts very much like the regular exec.library Wait() function, but it also waits on the quit signal. So the thread can check the return value of this function to see what event was set:

ULONG sig, win_sig = 1L<<window->UserPort->mp_SigBit;
sig = T_WaitForEvents(my_thread, win_sig);
if (sig & win_sig) /* The event was an IDCMP message */
else /*The event was a quit signal */


The Specifics

The code uses SignalSemaphores to keep everything in sync. A semaphore is a data type that the exec.library uses to keep multiple tasks from accessing the same data at the same time. A process has to obtain the semaphore and once it does that, it has exclusive access. If another process tries to obtain the same sempahore, exec will put the second process to sleep until the first process has released the semaphore. If a third process comes along and tries to obtain the semaphore, it too is put to sleep and won't be awoken until the second process has released the semaphore. Exec queues up all processes that want to obtain the semaphore in a First-In-First-Out (FIFO) order.

My thread code uses semaphores for a slightly different reason: it is used to avoid busy-waiting for the thread to terminate. I could have just used a boolean value that the thread could set to TRUE when it has started and FALSE when it terminates. However, when I want to wait for the thread to terminate, I would have to do something like this very bad code:

while (TRUE == thread->IsRunning) /* NULL */;

You should try to avoid this kind of looping on a multitasking system like the Amiga! By using a semaphore, I let exec do all the work: when I want to check if the thread has terminated I just try to obtain the semaphore. Exec will put my process to sleep until the thread has released the semaphore - which is the very last thing it does before it terminates.


Synchronizing Threads

One of the most important issues regarding threads is making sure that the integrity of data shared between thread is safe. You have no real way of knowing when one thread loses the processor and another gains control of it. This can cause problems when sharing data. An example will explain.

In the archive there are two programs: bank and banksync. Both programs create five very simplified bank accounts. Each account gets an initial balance of $1000.00. Then five threads are created and each one is assigned its own account. Then each thread repeatedly transfers some random amount of money from its account to on of the other accounts:

/* From bank.c */
while (!T_ShouldQuit(me)) {
    other = rand() % NUMTHREADS;
    if (my_act != other) { /* Don't transfer to self! */
        amt = rand() % (accounts[my_act] / 2);
        dummy1 = accounts[other] + amt;
        dummy2 = accounts[my_act] - amt;
        Delay(10); /* Hope for a task switch */
        accounts[other] = dummy1;
        accounts[my_act] = dummy2;
    }
    Delay(rand() % 10);
}

You make think this seems like a dumb way to do a transfer, but this is just an example! I want the transfer to be spread out over multiple lines of code. The reason is that the problem I want to create has a better chance of occuring if the thread is interrupted at some point in the if loop.

Suppose we have this situation: accounts[0]=1000, accounts[1]=1000, and accounts[2]=1000. Thread #0 has control and sets its local copy of other to 1 and amt to 125. dummy1 would be 1125 and dummy2 would be 875. So thread #0 is now ready to set accounts[1] to 1125 and accounts[0] to 825. But then it enters the Delay() and exec decides to switch control over to thread #2. It sets its local copy of other to 0 and amt to 300. dummy1 would be 1300 and dummy2 would be 700.

Now assume that when thread #2 enters the Delay() and exec switches back to thread #0. It sets accounts[1] to 1125 and accounts[0] to 825. Then thread #2 gets the cpu again, but here's the problem. When thread #2 calculated dummy1 and dummy2 accounts[0] was 1000. At this point however, accounts[0] is 825, but thread #2 is not aware of this. It sets accounts[0] to 1300 and its own account, accounts[2], to 700.

The accounts should always total to 3000 (assuming we have three accounts and each one had an initial balance of 1000). This value should never change because money is only being moved from account to account; no new money is earned and no money is ever spent. However, in the above example, the total is:

    accounts[0] = 1300
    accounts[1] = 1125
    accounts[2] =  700
    ------------------
    Total         3125

Obviously this is not correct!

The problem is being caused by task switching happening at critical points in the loop. The sollution: SignalSemaphores. The Windows API uses Critical Sections to mark a section of code as being accesible by only one task at a time. There is no such thing on the Amiga, but we can simulate it with SignalSemaphores:
/* From banksync.c */
while (!T_ShouldQuit(me)) {
    other = rand() % NUMTHREADS;
    if (my_act != other) {
        ObtainSemaphore(&ss);
            amt = rand() % accounts[my_act];
            dummy1 = accounts[other] + amt;
            dummy2 = accounts[my_act] - amt;
            Delay(10);
            accounts[other] = dummy1;
            accounts[my_act] = dummy2;
        ReleaseSemaphore(&ss);
    }
    Delay(rand() % 10);
}

The variable ss is a struct SignalSemaphore decalared globally and initialized in main() before the threads were created.

By using the semaphore, we are guaranteed that only one thread at a time will execute the code betweem ObtainSemaphore(&ss) and ReleaseSemaphore(&ss). Now the program will always work correctly! Of course there will be a performance hit, but there's not too much we can do about that.


Bonus!

Even though this is an article about C programming, I am adding my C++ Thread class at no extra charge. It is an abstract base class, which means that you can't instatiate any objects of type Thread. You must derive your own classes from the Thread class. The derived class should provide a constructor and override the pure virtual Start() function. There is an example in the archive.


The Code

The code is by no means complete! There is plenty of room for improvement and I encourage you to do so.

The source - FTP download 69,956 bytes.


Table of Contents