2. Parent and Child Tasks
So
far, you haven’t seen examples that create subtasks. A subtask is a
task created in the context of another task. The outer task is not
necessarily a parent task. There is no implied relationship. The default
task scheduler (the .NET Framework 4 thread pool) handles subtasks
differently than other tasks. Work stealing, as explained in the next section, can occur whenever subtasks are created.
In the following exercise, you will create a task and subtask.
Create an outer task and a subtask
Create a console application. In the Main function, create a new task by using the Task constructor. Initialize the task with a lambda expression. This will be the outer task in a task relationship.
var outer = new Task(() => {
In the lambda expression, display the name of the current task.
Console.WriteLine("Outer task.");
Still
within the lambda expression, create and start a task. Because you are
within an existing task, this is a subtask. In the lambda expression for
the new task, display the name of the task.
var inner=Task.Factory.StartNew(() => Console.WriteLine("Inner task."));
Wait for the subtask and close the lambda expression for the outer task.
inner.Wait(); });
To complete the example, start and then wait for the outer task. As part of the outer task, the inner task will also be started.
class Program
{
static void Main(string[] args)
{
Task outer = new Task(() =>
{
Console.WriteLine("Outer task.");
var inner=Task.Factory.StartNew(() => Console.WriteLine(
"Inner task."));
inner.Wait();
});
outer.Start();
outer.Wait();
}
}
You can convert a subtask
relationship into a parent/child relationship. You might want to do this
for a variety of reasons, including creating a hierarchy of tasks.
Instantiating a subtask does not immediately confer a parent and child
relationship between the two tasks. In addition to a subtask, you must
also choose the TaskCreationOptions.AttachedToParent option to indicate a parent/child relationship.
The TaskCreationOptions.AttachedToParent
method binds the lifetime of the parent and child tasks. In other
words, when waiting for the parent task, you are essentially waiting for
both the parent and the child task to complete. The parent might
complete before the child. If that occurs, the status of the parent task
becomes TaskStatus.WaitingForChildrenToComplete. When the child task eventually finishes, the status of the parent task is updated appropriately, for example, to TaskStatus.Completed or TaskStatus.Faulted.
Create
a parent task and a child task where the duration of the child task is
longer than the parent. Report the status when the parent task completes
execution.
Create a console application. Implement a computer-bound method.
static void DoSomething() { Thread.SpinWait(4000); }
In the Main function, create a new task by using the Task constructor. Initialize the task with a lambda expression. This will be the parent task.
var parent = new Task(() => {
In the lambda expression for the parent task, display the name of the current task.
Console.WriteLine("Parent task.");
Still in the lambda expression, create and start a child task by using the TaskFactory.StartNew method. In the lambda expression for the child task, sleep for 5,000 milliseconds by using the Thread.Sleep method. Be sure to define a parent/child relationship with the TaskCreationOptions.AttachedToParent option.
Task.Factory.StartNew(() => { Thread.Sleep(5000); },
TaskCreationOptions.AttachedToParent);});
Start the parent running with the Task.Start method. The child task is executed as part of the parent task.
parent.Start();
Wait
for the parent to complete. If the wait operation times out, query
whether the parent is waiting for child tasks to complete. If the parent
task is waiting for the child, display the status of the parent task.
class Program
{
static void Main(string[] args)
{
Task parent = new Task(() =>
{
Console.WriteLine("Parent task.");
Task.Factory.StartNew(() => { Thread.Sleep(5000); },
TaskCreationOptions.AttachedToParent);
});
parent.Start();
if ((!(parent.Wait(2000)) &&
(parent.Status == TaskStatus.WaitingForChildrenToComplete)))
{
Console.WriteLine("Parent completed but child not finished.");
parent.Wait();
}
}
}
3. The Work-Stealing Queue
Historically, thread
pools have a single global queue in which to place work items. The
thread pool dequeues work items from the global queue to provide work to
available threads. The thread pool exists in a multi-threaded
environment, so the global queue must be thread safe. The resulting synchronization can adversely affect the performance of the thread pool and indeed the overall application.
Because a single global queue is a
potential bottleneck, the .NET Framework 4 thread pool offers a global
queue and any number of local queues. This scheme allows work items to
be distributed across several queues and removes a single point of
synchronization. Parent tasks can be scheduled on the global queue,
while subtasks are placed on local queues.
Work items in the global
queue are accessed in a first-in, first-out (FIFO) manner, whereas local
queues are last-in, first-out (LIFO). In addition, local queues are
double-ended; they have a private side and a public side. The private
side is virtually lock free and is accessible only from the current
thread. Other threads access the queue from the public side, which is
controlled using synchronization. This explanation is somewhat of a
generalization, but hopefully it is sufficient to convey the essence of
work stealing.
Ultimately, work stealing
is a performance optimization. A subtask is placed on a local queue and
then scheduled (FIFO) to run on an available thread in the thread pool.
After the task completes, the now-available thread returns to the same
local queue for additional work. When this queue is empty, the thread
can freelance and help other local queues with pending work. If work is
found in another local queue, a task is dequeued (LIFO) and run on the
available thread. This process is called work stealing. Here is a typical scenario:
A primary task is started and placed on the global queue.
When the primary task runs, it creates a subtask. The new task is then placed on a local queue.
The
subtask completes. The thread pool searches for additional work to give
to the newly available thread, which it does as follows:
Search the same local queue for another task to dequeue (LIFO) and execute.
If the local queue is empty, find work for the thread on another local queue (LIFO).
If
a task is found on another local queue, it is dequeued (FIFO) and
executed. In essence, the thread just “stole” work from another local
queue. However, in this context, stealing is helpful.
Instead of stalling a
thread, work stealing keeps a thread busy even when its local queue is
empty. The stolen task is taken from the back of another local queue.
This must be synchronized for thread safety, because there might be
other work-stealing threads that need work at the same time. However,
that synchronization is an infrequent penalty, because most tasks are
taken from the private front end of the local queue.
Subtasks of long-running
threads are not placed on a local queue. Long-running tasks are
scheduled on a dedicated thread and not on a thread in the thread pool.
Similarly, subtasks of long-running tasks are scheduled on a dedicated
thread as well. Therefore, long-running tasks exist outside of the
thread pool and do not benefit from work stealing.
Here is a diagram of the work-stealing process.