Click here to Skip to main content
15,860,972 members
Articles / Programming Languages / C#

Multithreading, Delegates, and Custom Events

Rate me:
Please Sign up or sign in to vote.
4.95/5 (74 votes)
17 Mar 2010CPOL12 min read 187.6K   3.3K   310   63
Tie it all together and not lose your mind in the process

mtd_screenshot.jpg

Introduction

At my last job, I was tasked with writing an application that was capable of processing hundreds of customers using multi-threading. This application was also supposed to perform other tasks based on the status of that processing. All the while, it needed to notify the user of each item's progress. The implementation included a series of list boxes that showed where each customer was in the process (better than having a bunch of progress bars on the screen). The demo app presented here parrots that design so that you can see how the various threads post and custom events in order to illustrate UI updating from one or more threads.

Note: In the interest of brevity, I omitted debugging and exception handling from the code snippets posted in this article.

General

The C# forum here (and more recently, Quick Answers) is routinely peppered with questions about updating UI components from threads other than the main UI thread. Indeed, this is a common requirement for the simplest of DotNet applications. This article won't really be showing you anything new in this regard, but it does illustrate some organization considerations, and exercises the UI update problem, all in an application that might closely resemble something you'd need to write in the "real world".

This article implements the following elements:

  • Multi-threading
  • Use of a thread pool
  • Using Invoke to update the UI
  • Custom events
  • Random number generation
  • Liquid Nitrogen

The Thread Pool

As you may already know, DotNet already provides a thread pool object. However, its usability is hampered by several shortcomings, such as you can only have one at a time in a given application, and you can't remove threads once they're queued. For this reason, I use Ami Bar's SmartThreadPool from this CodeProject article. Among other things, this object provides the two afore-mentioned (missing) features, and you can modify the source code yourself if it doesn't quite fit your needs. If you want details regarding the implementation and use of this class, you should go read that article.

The ProcessThreadManager Object

This object inherits from List, and contains the ProcessThread objects (described below). I did this because the ProcessThread items needed to be contained in a list, and because I wanted to abstract out the functionality required to get the thread pool prepped and started.

In this object, we establish some control over the thread pool via properties:

/// <summary />
/// Get/set the thread pool
/// </summary />
public SmartThreadPool Pool              { get; set; }
/// <summary />
/// Get/set the number of concurrent threads that can be running at once
/// </summary />
public int             ConcurrentThreads { get; set; }
/// <summary />
/// Get/set the maximum number pf threads we will be running
/// </summary />
public int             MaxThreads        { get; set; }
/// <summary />
/// Get/set the default thread pool startup criteria
/// </summary />
public STPStartInfo    StpStartInfo      { get; set; }
/// <summary />
/// Get/set how long the thread pool is idle before it times out
/// </summary />
public int             PoolIdleTimeout   { get; set; }
/// <summary />
/// Get/set the progress reporting frequency to assign to all threads.
/// </summary />
public int             ProgressFrequency { get; set; }

The Reset() method prepares the thread pool, and creates the specified number of process threads. Notice however that the thread pool remains idle because the user has to click a button to get things rolling.

public void Reset(int maxThreads, int concurrentThreads, int frequency)
{
    Clear();
    this.MaxThreads        = maxThreads;
    this.ConcurrentThreads = concurrentThreads;
    this.ProgressFrequency = frequency;
    for (int i = 1; i <= MaxThreads; i++)
    {
        ProcessThread process = new ProcessThread(i);
        process.ProgressFrequency = this.ProgressFrequency;
        Add(process);
    }
    if (this.Pool != null)
    {
        this.Pool.Cancel();
        while (this.Pool.ActiveThreads != 0)
        {
            Thread.Sleep(50);
        }
        this.Pool.Dispose();
        this.Pool = null;
    }
    this.Pool = new SmartThreadPool(this.StpStartInfo);
}

After the thread pool (and its potential content threads) has been created, and the user clicks the Start button on the form, the Start method is called. As you can see, we don't have much to do here:

public void Start()
{
    if (this.Pool != null)
    {
        QueueWorkItems();
        this.Pool.Start();
    }
}

Queuing work items is equally unexciting (and as brief as the Start method), but it's a necessary chore for your SmartThreadPool.

private void QueueWorkItems()
{
    if (this.Pool != null)
    {
        foreach (ProcessThread thread in this)
        {
            IWorkItemResult workItem = thread.QueueProcess(this.Pool);
        }
    }
}

As you can see, there's not a whole lot going on in that class.

The ProcessThread Object

The threads we use here are very simple, progressing through three sit-and-spin processing "steps". In other words, their general functionality is kind of pointless and almost completely useless in a real application. They exist merely to take some time to complete, but otherwise, perform no useful task.

The only slightly interesting part of all that is the method at which we arrive at each thread's processing time. Instead of just setting a hard-wired run-time for each cycle of each thread, I use a random value, which is calculated like so:

DateTime now      = DateTime.Now;

int      seed     = (((now.Second * 1000) + now.Millisecond) % 23) * id;
Random   random   = new Random(seed);
int      step     = 0;
int      duration = 0;
do
{
    int lastStep = step - 1;
    duration = random.Next(500, 10000);
    if ((lastStep < 0) || 
        (duration < this.stepDurations[lastStep] - 500  || 
         duration > this.stepDurations[lastStep] + 500))
    {
        this.stepDurations[step] = duration;
        step++;
    }
} while (step <= 2);

Each step has a duration. This duration is arrived at by seeding a Random object with a value based on the current time. Since the details are fairly obvious by looking at the code, the reasons aren't. I wanted to make sure the seed was sufficiently different from the seed used any of the other process threads, so I did some additional math on the second/millisecond combination to try to ensure that.

Once seeded, I executed a do/while loop that ensured that the random values were far enough apart to allow the user to see the unordered processing that occurs in the demo application. I felt that this would prove that the threads are indeed starting, progressing, and finishing at their own established intervals. The result is that it is highly improbably that big blocks of threads will progress and finish all at the same rate. In the end though, this really has nothing to do with what the demo application's true intent.

Part of using the SmartThreadPool is putting a thread into the queue in order for it to "managed" within the pool. Once queued, it becomes a "work item" within the pool. Threads are executed in the order they appear in the queue, and while this normally isn't an issue in a real-world app, it's handy to be aware of this if it matters in your application. The ProcessThread object contains a method for queuing itself into the specified thread pool:

public IWorkItemResult QueueProcess(SmartThreadPool pool)
{
    workItemResult = null;
    if (pool != null)
    {
        if (threadPoolState == null)
        {
            threadPoolState = new object();
        }
        workItemResult = pool.QueueWorkItem(new WorkItemCallback(this.Start), 
                                            WorkItemPriority.Normal);
    }
    return workItemResult;
}

Once queued, a thread can be started by the thread pool. In the snippet above, notice that we specify the callback method in this object as the thread start delegate. As you know, this is the thread proper, and contains all of the actual processing code for the thread.

public object Start(object state)
{
	started = DateTime.Now;
	AdvanceStep(0);
	AdvanceStep(1);
	AdvanceStep(2);
	finished = DateTime.Now;
	RaiseEventProcessComplete();
	return state;
}

As mentioned before, the ProcessThread object proceeds through three distinct steps while processing. The reason is so that we can notify the UI of each thread's progress as processing continues.

Note: You may have noticed the line that contains RaiseEventProcessComplete();. The ProcessThread object posts several events as it processes itself. Well talk about these events a little later in the article.

The AdvanceStep method uses the previously determined interval for the specified step to establish its processing duration. It divides the total duration by 100 to establish the sleep interval required for each 1% of completion. It then goes into a loop and sleeps for the establish interval, and raises a progress event at the end of each sleep period. The loop ends when the total duration meets or exceeds the interval that was calculated for the current step (in the constructor).

private void AdvanceStep(int step)
{
    this.Step++;
    Progress = 0;
    PostStepChangeEvent(Step);
    int stepDuration = stepDurations[step];
    int span = 0;
    int interval = (int)(Math.Ceiling((double)stepDuration / 100d));
    do
    {
        Thread.Sleep(interval);
        span += interval;
        this.Progress++;
        if (this.ProgressFrequency > 0 && 
            this.Progress % this.ProgressFrequency == 0 || 
            this.Progress == 100)
        {
            RaiseEventProcessProgress();
        }
    } while (span < stepDuration);
    if (Progress < 100)
    {
        Progress = 100;
        RaiseEventProcessProgress();
    }
    Thread.Sleep(interval);
}

After I wrote the original code, I decided that it might be desirable (within the context of the demo application) to sleep longer before reporting progress. This would ease the load on the CPU because threads were sleeping longer and fewer progress events would be posted. So, I provided an alternative interval sleep control loop if you wanted to play around with that:

int interval = (int)(Math.Ceiling((double)stepDuration / 100d)) * this.ProgressFrequency;
do
{
    Thread.Sleep(interval);
    span += interval;
    if (this.ProgressFrequency > 0)
    {
        this.Progress += ProgressFrequency;
        RaiseEventProcessProgress();
    }
} while (span < stepDuration);

Keep in mind that this will have a direct effect on how long the thread processes, because of the last line in the method. In order to make sure the progress bar allows the user to see that a thread is complete, we sleep for the length of the interval after posting the 100% progress event. This means you're going to also have to change the last line of the method to divide the interval by the ProgressFrequency just to make sure you're not waiting too long for the thread to actually "complete".

Thread.Sleep((int)(interval / (double)this.ProgressFrequency));

In order for the items to be easily usable in the UI (when adding them to listboxes), I overrode the ToString() method to show the id number of the thread:

public override string ToString()
{
    return string.Format("{0:00000}", ProcessID);
}

Up to this point, we've established the core framework for the application. Now, it's time to get dirty with the events, so roll up your sleeves and get ready to dig in.

Custom Events

Because we want to keep the user informed of the progress of the threads, the demo application makes heavy use of custom events and delegates. Because there is already sufficient reference material on the whys and wherefores of custom events and how they work, I'm not going to bother you with those kinds of details. Instead, I'll keep the discussion confined to the context of the demo application. Let's start with the ProcessThread object.

ProcessThread Events

The first thing I had to do was determine what kind of events I wanted to post, and came up with the following events:

  • Step advancement, allowing the UI to move a thread item from one list box to another
  • Processing progress, allowing the UI to reflect a selected thread's actual processing progress within a given "step".
  • Thread complete, so we can react to thread processing being completed.

I started by defining the events in the ProcessThread object:

public event ProcessStep1Handler    ProcessStep1    = delegate{};
public event ProcessStep2Handler    ProcessStep2    = delegate{};
public event ProcessStep3Handler    ProcessStep3    = delegate{};
public event ProcessCompleteHandler ProcessComplete = delegate{};
public event ProcessProgressHandler ProcessProgress = delegate{};

Next, I defined the event raising methods. Since they're all pretty much identical, I'm only showing one of them here:

protected void RaiseEventProcessStep1()
{
    ProcessStep1(this, new ProcessThreadEventArgs());
}

I suppose I could have been a lot more elegant about it, but what the heck, this is only a demo app, and I'm not currently employed, so I had plenty of time for typing. :)

Next, I had to add some code to the application form, because that's where we'd be consuming the events.

The Form Event Handlers

The first thing that's needed are delegate definitions for the methods that will be used during the Invoke calls (when a ProcessThread object posts an event).

private delegate void DelegateUpdateListboxStep1(ProcessThread thread);
private delegate void DelegateUpdateListboxStep2(ProcessThread thread);
private delegate void DelegateUpdateListboxStep3(ProcessThread thread);
private delegate void DelegateUpdateListboxComplete(ProcessThread thread);
private delegate void DelegateUpdateProgress(ProcessThread thread);

As you can see, we have an event handler delegate for every event. These definitions allow us to specify a form method that can be used to interact with the UI. Those methods look something like this:

//--------------------------------------------------------------------------------
private void UpdateListboxStep1(ProcessThread thread)
{
    RemoveFromOtherListboxes(this.listboxStep1, thread);
}

//--------------------------------------------------------------------------------
private void UpdateListboxStep2(ProcessThread thread)
{
    // clear the step 1 progressbar if necessary
    if (this.listboxStep1.SelectedItem == thread)
    {
        this.progressStep1.Value = 0;
    }
    RemoveFromOtherListboxes(this.listboxStep2, thread);
}

//--------------------------------------------------------------------------------
private void UpdateListboxStep3(ProcessThread thread)
{
    // clear the step 2 progressbar if necessary
    if (this.listboxStep2.SelectedItem == thread)
    {
        this.progressStep2.Value = 0;
    }
    RemoveFromOtherListboxes(this.listboxStep3, thread);
}

//--------------------------------------------------------------------------------
private void UpdateProgressBars(ProcessThread thread)
{
    switch (thread.Step)
    {
        case 1 :
            if (this.listboxStep1.Items.Count > 0 && 
                this.listboxStep1.SelectedItem == thread)
            {
                this.progressStep1.Value = thread.Progress;
            }
            break;
        case 2 :
            if (this.listboxStep2.Items.Count > 0 && 
                this.listboxStep2.SelectedItem == thread)
            {
                this.progressStep2.Value = thread.Progress;
            }
            break;
        case 3 :
            if (this.listboxStep3.Items.Count > 0 && 
                this.listboxStep3.SelectedItem == thread)
            {
                this.progressStep3.Value = thread.Progress;
            }
            break;
    }
}

//--------------------------------------------------------------------------------
private void UpdateListboxComplete(ProcessThread thread)
{
    // clear the step 3 progressbar if necessary
    if (this.listboxStep3.SelectedItem == thread)
    {
        this.progressStep3.Value = 0;
    }
    RemoveFromOtherListboxes(this.listboxComplete, thread);
    RemoveEventHandlers(thread);
}

You may have noticed that in the UpdateListboxComplete delegate method, I took the opportunity to unattach the event handlers for the finished thread. It's simply convenient to do it this way, and we can do it because we're theoretically done with the thread anyway.

Somewhere in the form code, we need to attach to the event handlers in the ProcessThread objects, like so (the form architecture will be discussed a little later):

for (int i = 0; i < this.processManager.Count; i++)
{
    this.processManager[i].ProcessStep1    += new ProcessStep1Handler(Form1_ProcessStep1);
    this.processManager[i].ProcessStep2    += new ProcessStep2Handler(Form1_ProcessStep2);
    this.processManager[i].ProcessStep3    += new ProcessStep3Handler(Form1_ProcessStep3);
    this.processManager[i].ProcessProgress += new ProcessProgressHandler(Form1_ProcessProgress);
    this.processManager[i].ProcessComplete += new ProcessCompleteHandler(Form1_ProcessComplete);
}

Finally, we need to add meat to the event handlers. They're all pretty much the same, so I'll only show one of them here:

void Form1_ProcessComplete(object sender, ProcessThreadEventArgs e)
{
    if (this.listboxComplete.InvokeRequired)
    {
        DelegateUpdateListboxComplete method = new DelegateUpdateListboxComplete(UpdateListboxComplete);
        Invoke(method, sender);
    }
    else
    {
        UpdateListboxComplete(sender as ProcessThread);
    }
}

In the case of our demo application, the list boxes and the progress bars are the intended update targets for these events, so having the if InvokeRequired line is kinda pointless. However, since we don't really know how this code might be modified in the future, we really should do it this way (although one of the event handlers doesn't check - it just assumes that Invoke is required).

You may have noticed that regardless of which way the control is updated, the same delegate method (UpdateListboxComplete) is used. That's because the actual delegate method doesn't care how it's called or where it's called from. It only matters to the UI thread, and that's why we use Invoke when updating from another thread.

The Demo Application

The demo is a simple WinForm application, comprised of a few basic controls. The user can specify the maximum number of threads to process (1 to 255), and how many threads to run concurrently (1 to 100). Beyond that, it's a simple matter of pushing buttons and watching it run.

When you click the Load Queue button, the process manager creates and initializes the pool, and creates the specified number of threads. This is also where the event handlers are attached for the threads.

private void buttonLoadQueue_Click(object sender, EventArgs e)
{
    if (this.processWorker.IsBusy)
    {
        this.processWorker.CancelAsync();
    }
    if (!string.IsNullOrEmpty(this.textboxProcessCount.Text))
    {
        // if you want to play with it, change the last parameter in the following method call
        this.processManager.Reset(Convert.ToInt32(this.textboxProcessCount.Text), 
                                  this.trackbarConcurrent.Value, 
                                  10);

        // add the event handlers for all of the threads
        for (int i = 0; i < this.processManager.Count; i++)
        {
            this.processManager[i].ProcessStep1    += new ProcessStep1Handler(Form1_ProcessStep1);
            this.processManager[i].ProcessStep2    += new ProcessStep2Handler(Form1_ProcessStep2);
            this.processManager[i].ProcessStep3    += new ProcessStep3Handler(Form1_ProcessStep3);
            this.processManager[i].ProcessProgress += new ProcessProgressHandler(Form1_ProcessProgress);
            this.processManager[i].ProcessComplete += new ProcessCompleteHandler(Form1_ProcessComplete);
        }
        this.listboxQueued.Items.Clear();
        this.listboxQueued.Items.AddRange(this.processManager.ToArray());
    }
    this.buttonStart.Enabled     = true;
    this.buttonLoadQueue.Enabled = false;
}

When you click the Start button, the button controls are appropriately enabled/disabled, and the process manager starts the thread pool. This button cannot be clicked if the thread pool is running.

private void buttonStart_Click(object sender, EventArgs e)
{
    this.buttonStop.Enabled          = true;
    this.buttonClear.Enabled         = false;
    this.buttonStart.Enabled         = false;
    this.buttonLoadQueue.Enabled     = false;
    this.textboxProcessCount.Enabled = false;
    this.processWorker.RunWorkerAsync();
}

When you click the Stop button, the threads that are running are canceled, the thread pool is stopped, the list boxes are cleared, and the buttons are appropriately enabled/disabled. This button cannot be clicked unless the thread pool is running.

private void buttonStop_Click(object sender, EventArgs e)
{
    StopThreadPool();
    this.listboxComplete.Items.Clear();
    this.listboxStep1.Items.Clear();
    this.listboxStep2.Items.Clear();
    this.listboxStep3.Items.Clear();
    this.listboxQueued.Items.Clear();
    this.listboxQueued.Items.AddRange(this.processManager.ToArray());
    this.buttonStop.Enabled          = false;
    this.buttonClear.Enabled         = true;
    this.buttonStart.Enabled         = true;
    this.buttonLoadQueue.Enabled     = true;
    this.textboxProcessCount.Enabled = true;
}

When you click the Clear button, the list boxes are emptied out, the threads are deleted (the process manager has 0 items), and the buttons are appropriately enabled/disabled. This button cannot be clicked while the thread pool is running.

private void buttonClear_Click(object sender, EventArgs e)
{
    this.listboxComplete.Items.Clear();
    this.listboxStep1.Items.Clear();
    this.listboxStep2.

When entering the number of threads to be processed, the user's input is verified to be only numeric data. This is handled in the TextChanged event handler.

private void textboxProcessCount_TextChanged(object sender, EventArgs e)
{
    if (!this.validatingText)
    {
        this.validatingText = true;
        if (!string.IsNullOrEmpty(this.textboxProcessCount.Text))
        {
            int    caretPos = this.textboxProcessCount.SelectionStart;
            string text     = this.textboxProcessCount.Text;
            int    value;
            if (!int.TryParse(text, out value))
            {
                this.textboxProcessCount.Text           = text.Substring(0, text.Length - 1);
                this.textboxProcessCount.SelectionStart = text.Length - 1;
            }
        }
    }
    this.validatingText = false;
}

The code that positions the caret is somewhat lacking because I didn't feel like dealing with it. I make the admittedly lame (and lazy) assumption that the last character typed was at the end of the input field. If you're not in fact at the end of the input field and you type an invalid character, I suspect that the cursor will position itself at the end of the text as a result. I haven't actually tried it, but I leave it here for your general bemusement.

The final aspect of this demo app is that I monitor the status of the thread pool itself with a BackgroundWorker object. Like pretty much everything else in the demo, I didn't absolutely have to do it this way, but it was damn convenient, and I'm not one to go to great lengths to shirk a convenience. Here's the code:

//--------------------------------------------------------------------------------
void processWorker_DoWork(object sender, DoWorkEventArgs e)
{
    BackgroundWorker worker = sender as BackgroundWorker;
    if (processManager.Count > 0)
    {
        this.processManager.Start();
        while (!worker.CancellationPending && 
               this.processManager.Pool.ActiveThreads > 0)
        {
            Thread.Sleep(100);
        }
    }
}

//--------------------------------------------------------------------------------
void processWorker_RunWorkerCompleted(object sender, RunWorkerCompletedEventArgs e)
{
    this.buttonStart.Enabled         = true;
    this.buttonLoadQueue.Enabled     = true;
    this.textboxProcessCount.Enabled = true;
}

Essentially, the background worker starts the process manager thread pool and sleeps for 100 milliseconds before seeing if the thread pool is idle (0 active threads). When complete, it enables/disables the appropriate buttons.

The Progress Bars

The progress events are only visible in the form if you click an item in one of the "Step" list boxes. At that point, you can watch the progress of the selected item. When the item progresses to the next listbox, the progress bar that was showing the progress is set to a value of 0, and the item's progress is no longer tracked in the subsequent listbox. It would an easy exercise for the programmer to allow the user to select an item in the Queued listbox and watch it's progress all the way through the processing cycle.

In Closing

The demo app exercises the thread pool and illustrates use of Invoke enabling non-UI threads to affect the UI. As stated before, virtually all applications if any complexity requires this functionality. Add the power and flexibility of SmartThreadPool, and you can literally do anything you can dream up.

History

03/17/2010 - Fixed some spelling errors.

02/14/2010 - An exception was being thrown in the demo application because the Stop button was enabled when it shouldn't have been. I also changed the demo app so that the Clear button was enabled when the thread processing was completed.

02/08/2010 - Updated to add text that got chopped off during initial posting. Also fixed some of the descriptive text, and rearranged some of the code snippets.

02/07/2010 - Original version

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)


Written By
Software Developer (Senior) Paddedwall Software
United States United States
I've been paid as a programmer since 1982 with experience in Pascal, and C++ (both self-taught), and began writing Windows programs in 1991 using Visual C++ and MFC. In the 2nd half of 2007, I started writing C# Windows Forms and ASP.Net applications, and have since done WPF, Silverlight, WCF, web services, and Windows services.

My weakest point is that my moments of clarity are too brief to hold a meaningful conversation that requires more than 30 seconds to complete. Thankfully, grunts of agreement are all that is required to conduct most discussions without committing to any particular belief system.

Comments and Discussions

 
GeneralRe: Great Pin
Marcelo Ricardo de Oliveira17-Mar-10 12:31
mvaMarcelo Ricardo de Oliveira17-Mar-10 12:31 
GeneralExceptions from background threads Pin
Uwe Keim10-Feb-10 20:18
sitebuilderUwe Keim10-Feb-10 20:18 
GeneralRe: Exceptions from background threads Pin
#realJSOP10-Feb-10 23:38
mve#realJSOP10-Feb-10 23:38 
GeneralGood one ! Pin
Abhijit Jana8-Feb-10 11:25
professionalAbhijit Jana8-Feb-10 11:25 
GeneralNice Pin
sam.hill8-Feb-10 5:13
sam.hill8-Feb-10 5:13 
GeneralRe: Nice Pin
#realJSOP8-Feb-10 5:25
mve#realJSOP8-Feb-10 5:25 
GeneralRe: Nice Pin
Phil J Pearson8-Feb-10 11:58
Phil J Pearson8-Feb-10 11:58 
GeneralRe: Nice Pin
#realJSOP8-Feb-10 12:42
mve#realJSOP8-Feb-10 12:42 
Phil J Pearson wrote:
I had always thought that was really a picture of you!


I never said it wasn't a picture of me. I said I scanned it out of Wired Magazine.

The mystery, therefore, survives.

Smile | :)
.45 ACP - because shooting twice is just silly
-----
"Why don't you tie a kerosene-soaked rag around your ankles so the ants won't climb up and eat your candy ass..." - Dale Earnhardt, 1997
-----
"The staggering layers of obscenity in your statement make it a work of art on so many levels." - J. Jystad, 2001

GeneralGreat! Pin
Wes Aday8-Feb-10 4:41
professionalWes Aday8-Feb-10 4:41 
Generalgood job, I love that SmartThreadPool, its ace. Pin
Sacha Barber8-Feb-10 2:17
Sacha Barber8-Feb-10 2:17 
GeneralRe: good job, I love that SmartThreadPool, its ace. Pin
#realJSOP8-Feb-10 2:48
mve#realJSOP8-Feb-10 2:48 
GeneralRe: good job, I love that SmartThreadPool, its ace. Pin
Sacha Barber8-Feb-10 3:01
Sacha Barber8-Feb-10 3:01 
GeneralThere Seems To Be A Problem Pin
#realJSOP7-Feb-10 13:31
mve#realJSOP7-Feb-10 13:31 
GeneralRe: There Seems To Be A Problem Pin
Chris Maunder7-Feb-10 14:53
cofounderChris Maunder7-Feb-10 14:53 
GeneralRe: There Seems To Be A Problem Pin
#realJSOP8-Feb-10 0:06
mve#realJSOP8-Feb-10 0:06 
GeneralRe: There Seems To Be A Problem Pin
#realJSOP8-Feb-10 0:29
mve#realJSOP8-Feb-10 0:29 
GeneralI forgot to mention [modified] Pin
#realJSOP7-Feb-10 11:41
mve#realJSOP7-Feb-10 11:41 

General General    News News    Suggestion Suggestion    Question Question    Bug Bug    Answer Answer    Joke Joke    Praise Praise    Rant Rant    Admin Admin   

Use Ctrl+Left/Right to switch messages, Ctrl+Up/Down to switch threads, Ctrl+Shift+Left/Right to switch pages.