Introduction to Threads
A GUI program is responsive when it responds to events very soon after they are generated.
Why an Interface Can Seem Frozen
Imagine a program with a window, a Start button, and a progress display. You click Start, and for a few seconds nothing on screen changes. You click the Cancel button, and nothing happens. The window does not redraw, and the mouse seems to stop working. Eventually the program catches up and processes all of your clicks at once. The program did not crash, but it did not respond to you while it was busy.
This article explains why that happens and what a programmer can do about it. The central question is simple: how can a GUI program keep answering the user when some of its work takes a long time? To answer it, we first need a precise picture of what happens to events after the user generates them, and then we need to see how work can be moved out of the way so that the interface stays available.
What Responsiveness Means
A GUI program is responsive when it responds to events very soon after they are generated. An event is something the program receives, such as a button click, a key press, or a mouse movement. Responsiveness is a property of timing: the question is not only whether the program eventually handles an event, but how soon after the event occurs the program reacts to it.
The source makes a practical point about how long a delay can be before it becomes a problem. A delay of more than a fraction of a second can be very annoying to a user. The source also notes that modern computers can carry out an impressive amount of computation in a fraction of a second. Together these facts suggest a working target: keep the time between an event and its response very short.
How Events Wait in a Queue
When a user clicks a button, types a key, or moves the mouse, the program receives an event. Events do not get handled the instant they happen. As they are generated, they go into a queue. The computer cannot respond to an event until the event-handler methods for the previous events have finished their work.
This ordering has an important consequence. While one event handler is executing, every other event must wait in the queue. The program is not ignoring those events; it simply has not reached them yet. If the running handler is quick, the wait is too short for anyone to notice. If the running handler is slow, the queue backs up, and the interface appears frozen for as long as that one handler keeps running.
The diagram shows the key timing rule. The second click is generated while the first handler is still running, but it is only delivered to a handler after the first handler has finished. Nothing is lost; the event is just late. Reading the diagram from top to bottom gives the order of events, and it is this order that causes the frozen feeling when a handler runs for a long time.
What do you think happens?
If the first click's handler is still running when a second click arrives, when does the second click get handled?
Reveal answer
After the first handler has finished
Events go into a queue and are not handled the instant they happen. The second click is generated during the first handler's run, but it is only delivered to a handler after that handler finishes. Nothing is lost; the event is just late.
Why Slow Handlers Freeze the Screen
A slow Start handler
A Start button's handler performs a computation that takes several seconds. The user clicks Start, then clicks Cancel. What does the user see, and why?
- 1Click one enters the queue
The Start click is generated and placed in the queue, and its handler begins running on the event-handling thread.
- 2The handler keeps running
Because the computation is long, the handler does not return for several seconds. The event-handling thread is busy the whole time.
- 3Click two waits
The Cancel click is generated during this period and waits in the queue. The program has not ignored it; it simply has not reached it yet.
- 4The screen appears frozen
Nothing redraws and the mouse seems dead, because no event is being processed until the long handler finishes.
- 5Queued clicks are processed together
Once the handler finishes, the program catches up and processes the waiting clicks, which is why the interface seems to respond all at once after a delay.
The program does not crash, but it does not respond to the user while the long handler is running. The cause is the handler's length, not a fault in the queue itself.
Most event handlers can be kept fast by simply doing less work in them. The working rule that follows from the queue is to keep each event-handling method short, so that the queue of waiting events is cleared quickly. The rest of this article covers what to do when a computation is too large to fit into a short handler.
Moving Heavy Work to a Background Thread
Some computations are simply too large to fit into an event handler without causing a noticeable freeze. The source's solution for those cases is to perform the computation in another thread that runs in parallel with the event-handling thread. A thread is a separate flow of execution, and two threads can make progress at the same time.
Because the computation runs in its own thread, the event-handling thread is free to take the next event from the queue and process it while the computation is still ongoing. The computer can therefore respond to user events even while the heavy work continues. The source describes such a computation as being done in the background. The word background describes where the work happens relative to the interface: it proceeds beside the user's interactions rather than blocking them.
Read the diagram as a division of labor. The event-handling thread keeps draining the queue and remains available to the user. The heavy arithmetic or processing lives in the background thread. The source describes the idea of running the computation in parallel and says that this makes it possible to respond to user events during the computation. It does not spell out how a finished result is passed back to the interface, so that detail is outside what this article can establish.
Packaging Work as Runnable Objects
Before a job can be handed to a thread, the job has to exist as something a program can pass around. In an object-oriented design, each subtask is usually represented by an object. Because a subtask is a computation, it makes sense for its object to carry a method that performs that computation. Running the subtask then reduces to one action: calling that method.
In the source's own program the method is named run(), and the task object implements the standard Runnable interface. The point of the interface is that it provides a common, standard way to describe a computational task, so any code that knows how to run a Runnable can run any task that follows the same pattern. Each subtask is typically represented as a Runnable object whose run() method performs its computation, which lets any available thread execute it.
This separation is what makes the rest of the design possible. Once a task is an object with a run() method, the program does not need to know in advance which thread will eventually execute it. It only needs a way to get the object to some thread that is free to call run(). Task decomposition, the step of splitting a large job into independent subtasks, is the first half of this story, and packaging each subtask as a Runnable is the second.
Thread Pools and Idle Threads
A Runnable could be given its own thread immediately, and that is a legitimate option. The trouble appears when there are many tasks. Each thread that is created carries overhead, so a program that creates a thread for every one of many tasks spends a large share of its effort on creating and managing threads rather than on the computations themselves. The sensible alternative is to create only a few threads and let each of them execute a number of tasks, one after another, as the work arrives.
A thread pool is a group of threads that can execute multiple tasks, with each task assigned to any idle thread in the pool rather than to a newly created thread. Pools exist to avoid the significant overhead of creating a new thread for each task, so a few threads are reused across many tasks.
The diagram shows the flow in words as well. A task is not tied to a particular thread. Each task joins the pool, and whichever pooled thread happens to be idle takes it. A thread that finishes its work does not disappear; it becomes idle again and is ready for the next task that arrives. Because the pool is reused, the same few threads can carry out a long series of tasks over the life of the program.
Choosing How Many Threads
A pool needs a size, and the right size is not fixed by any universal rule. The source is direct about this: the optimal number of threads is not entirely clear, and it depends on the particular problem being solved. The guiding goal is to keep all of the computer's processors busy. When every processor always has a thread ready to run, the hardware is being used well.
Sizing a pool for image tiles
A program must process hundreds of image tiles, and each tile is almost pure computation. The machine has four processors. How many threads should the pool contain?
- 1Identify the work type
Each tile is computation-bound. A thread that starts on a tile keeps working on it rather than waiting for something else.
- 2Apply the one-per-processor pattern
For this kind of work, one thread per available processor keeps each processor occupied without wasting capacity.
- 3Compute the pool size
Four processors give four threads. The hundreds of tiles are fed to these four threads, each of which takes the next tile when it becomes idle.
A pool of four threads suits this computation-bound problem. The pattern does not carry over unchanged to problems whose threads spend time blocked, as the next comparison shows.
| Kind of work | Typical thread count guidance | Reason |
|---|---|---|
| Computation-bound tasks (for example, image tiles) | One thread per processor | Each thread keeps its processor busy, so extra threads add no capacity |
| Tasks that block while waiting (for example, waiting on I/O or a resource) | Extra threads beyond one per processor | While one thread is blocked, another ready thread can use the processor |
How the type of work changes the ideal pool size
The source explains that a thread can block for a non-trivial amount of time while it waits for some event or for access to some resource. While a thread is blocked, its processor has nothing to do unless another thread is ready to run. For that reason, a problem with blocking threads benefits from having extra threads available, so the processor can run them while others wait. The source notes that this kind of blocking comes up again later in the course, when threads are used with networking, where waiting for data over a connection is exactly the kind of blocking that makes extra threads worthwhile.
Common Misunderstandings About Threads
Common mistake Thinking a frozen interface means the program crashed
What it looks likeThe Start and Cancel clicks appear ignored, but the program later processes them all.
Why it failsThe events are queued and delivered after the running handler finishes. The program is busy, not broken.
Do this insteadRecognize that a slow handler delays the queue, and shorten the work each handler does.
Common mistake Believing queued events are discarded
What it looks likeA second click generated during a long handler seems to vanish.
Why it failsNothing is lost; the event is simply late, since it waits in the queue until a handler can take it.
Do this insteadTreat queued events as delayed rather than lost when reasoning about the interface.
Common mistake Giving every task its own new thread
What it looks likeA program with hundreds of small image pieces starts hundreds of threads.
Why it failsEach thread carries significant overhead, so creating one per small job wastes effort.
Do this insteadPackage the jobs as Runnable objects and submit them to a thread pool of a modest size.
Common mistake Assuming a fixed rule for pool size
What it looks likeAlways using exactly one thread per processor regardless of the work.
Why it failsThe source says the optimal size depends on the problem, and blocking tasks benefit from extra threads.
Do this insteadAsk whether the tasks are computation-bound or spend time blocked, then size the pool accordingly.
Common mistake Assuming a task is tied to one specific thread, when in a pool any idle thread may run it
Checking Your Understanding
A GUI handler for a Start button runs a long computation directly on the event-handling thread. Describe what happens to a Cancel click that arrives during the computation, and explain how moving the computation to a background thread would change the outcome.
Hints
- Think about where the Cancel click waits while the handler is running.
- Consider which thread would be free to take the next event from the queue if the computation runs elsewhere.
Key Takeaways
- A GUI program is responsive when it responds to events very soon after they are generated, and events that arrive during a slow handler wait in a queue until that handler finishes.
- Keeping event-handling methods short keeps the queue moving; computations too large for a handler can run in a background thread in parallel with the event-handling thread.
- Each unit of work is packaged as a Runnable object whose
run()method performs the computation, so any available thread can execute it. - A thread pool reuses a modest group of threads, assigning each task to any idle thread, which avoids the overhead of creating a new thread per task.
- The ideal pool size depends on the problem: one thread per processor suits computation-bound work, while tasks that block while waiting benefit from extra threads.