C#/.NET 3.0 线程入门指南:第 1 部分






4.78/5 (521投票s)
C#/.NET 线程入门指南。
引言
我不得不说,我就是那种不做什么事情就会感到无聊的人。现在,我觉得我已经掌握了 WPF 的基础知识,是时候将注意力转向其他事情了。
我有一长串需要处理的事情,比如 WCF/WF/CLR 通过 C# 2.0 版本的书籍,但我最近遇到了一件(虽然最终放弃了)事情,这需要我深入了解线程。虽然我认为自己在线程方面相当不错,但我还是觉得,嗯,我处理线程还行,但总能做得更好。因此,我决定写一系列关于 .NET 线程的文章。这个系列无疑将借鉴一本我购买的出色的 Visual Basic .NET 线程手册,它很好地弥补了 MSDN 的不足,现在也为你提供了帮助。
我猜这个话题将涵盖从简单到中等再到高级的各种内容,其中会涉及很多 MSDN 上有的知识,但我希望能够加入我自己的见解。
我不知道确切的时间表,但它最终可能会是这样的:
- C#/.NET 线程简介(本文)
- 线程生命周期/线程机会/陷阱
- 同步
- 线程池
- UI 中的多线程(WinForms / WPF / Silverlight)
- 多线程的未来(任务并行库)
我想最好的方式就是直接开始。不过在开始之前有一点需要注意,我将使用 C# 和 Visual Studio 2008。
我将在本文中尝试涵盖的内容是:
我想这足够写一篇文章了。
什么是进程
当用户启动一个应用程序时,会为其分配内存和大量资源。这些内存和资源的物理分离称为进程。一个应用程序可以启动多个进程。需要注意的是,应用程序和进程完全不是一回事。
你们应该都知道,可以使用任务管理器在 Windows 中查看正在运行的进程/应用程序。
这是我运行的应用程序数量
这是进程列表,可以看到有更多进程在运行。应用程序可能涉及一个或多个进程,每个进程都有自己独立的数据、执行代码和系统资源。
你可能会注意到上面提到了 CPU 使用率。这是因为每个进程都有一个由计算机 CPU 使用的执行序列。这个执行序列称为 **线程**。线程由 CPU 上使用的寄存器、线程使用的堆栈以及一个用于跟踪线程状态(线程局部存储,TLS)的容器来定义。
创建一个进程包括启动进程并使其在某个指令点开始运行。这通常称为主线程或主要线程。该线程的执行序列在很大程度上取决于用户代码的编写方式。
时间片
所有这些进程都想占用 CPU 时间周期的一部分,这如何管理呢?好吧,每个进程在 CPU 上都会获得一个时间片(量子),在这个时间片内(进程)可以使用 CPU。这个时间片**绝不**应被视为固定不变;它会受到操作系统和 CPU 类型的影响。
多线程进程
如果我们需要一个进程同时执行多项任务,例如同时查询 Web 服务和写入数据库,会发生什么?幸运的是,我们可以将一个进程拆分,以共享分配给它的时间片。这是通过在当前进程中生成新线程来完成的。这些额外的线程有时被称为工作线程。这些工作线程共享进程的内存空间,该空间与系统上的所有其他进程隔离。在同一进程中生成新线程的概念称为**自由线程**。
有些人(包括我)可能来自 VB 6.0,你们会知道,当时我们有公寓线程模型,其中每个新线程都在自己的进程中启动,并获得自己的数据,因此线程无法共享数据。让我们看看一些图示,这相当重要,对吧?
在这种模型下,每次你想做一些后台工作,它都会在自己的进程中完成,因此被称为**进程外**。
通过自由线程,我们可以让 CPU 使用相同的进程数据执行额外的线程。这比单线程公寓好得多,因为我们获得了额外线程的所有优势,并且能够共享相同的进程数据。
注意:同一时间只有一个线程在 CPU 上运行。
如果我们回到任务管理器并更改视图以包含线程计数,我们可以看到类似这样的情况
这表明每个进程显然可以有多个线程。那么,所有这些调度和状态信息是如何管理的?我们将在下一步中进行讨论。
线程局部存储
当线程的时间片到期时,它不会停止等待轮到自己。请记住,CPU 一次只能运行一个线程,因此当前线程需要被下一个要获得 CPU 时间的线程替换。在此之前,当前线程需要存储其状态信息,以便能够再次正常执行。这就是 TLS 的作用。存储在 TLS 中的寄存器之一是程序计数器,它告诉线程接下来要执行哪个指令。
中断
进程无需相互了解即可正确调度。这实际上是操作系统的任务。即使是操作系统也有一个主线程,有时称为系统线程,它负责调度所有其他线程。它通过中断来实现这一点。中断是一种机制,它会导致正常的执行流在不知情的情况下分支到计算机内存中的其他位置。
操作系统决定线程有多少执行时间,并将指令放入当前线程的执行序列中。由于中断包含在指令集中,因此它是软件中断,这与硬件中断不同。
中断是除最简单的微处理器之外的所有处理器都使用的一项功能,它允许硬件设备请求关注。当收到中断时,微处理器会暂时挂起正在运行的代码的执行,并跳转到一个称为中断处理程序的特殊程序。中断处理程序通常会处理需要关注的设备,然后返回到先前执行的代码。
所有现代计算机中的中断之一由计时器控制,其功能是在周期性间隔请求关注。处理程序通常会递增一些计数器,查看是否有任何有趣的事情应该发生,如果没有(还)有趣的事情,则返回。在 Windows 中,可能发生的“有趣”事情之一是线程时间片到期。发生这种情况时,Windows 会强制执行切换到另一个线程,而不是被中断的那个线程。
一旦放置了中断,操作系统就会允许线程执行。当线程遇到中断时,操作系统使用一个称为中断处理程序的特殊函数将线程的状态存储在 TLS 中。一旦线程时间片超时,它就会被移到其给定优先级的线程队列末尾(稍后详细讨论),以等待再次轮到自己。
如果线程未完成或需要继续执行,这还可以。如果线程决定它目前不需要更多 CPU 时间(也许在等待某个资源),那么它会将其时间片让给另一个线程,会发生什么?
这取决于程序员和操作系统。程序员进行让步(通常使用 `Sleep()` 方法);然后线程会清除操作系统可能放置在其堆栈中的任何中断。然后模拟一个软件中断。线程被存储在 TLS 中,并像之前一样移到队列的末尾。
但是,操作系统可能已经在线程堆栈中放置了一个中断,在线程被打包起来之前必须清除它;否则,当它再次执行时,可能会在不应该被中断的时候被中断。操作系统会(谢天谢地)做到这一点。
线程休眠和时钟中断
正如我们刚才所说,线程可能会决定让出 CPU 时间来等待资源,但这可能需要 10 到 20 分钟,因此程序员可以选择让线程休眠,这将导致线程被打包到 TLS 中。但它不会进入可运行队列;它会进入一个**休眠队列**。为了让休眠队列中的线程再次运行,它们需要一种不同的中断,称为**时钟中断**。当线程进入休眠队列时,会安排一个新的时钟中断,在线程应该唤醒的时间点触发。当一个时钟中断发生并与休眠队列中的条目匹配时,该线程将被移回**可运行队列**。
线程中止/线程完成
万物皆有终结。当一个线程完成或被程序化中止时,该线程的 TLS 将被释放。进程中的数据仍然存在(记住,它被该进程的所有线程共享,可能不止一个),并且只有在进程本身停止时才会被释放。
所以,我们已经讨论了一些关于调度的内容,但我们也说过 TLS 存储了线程的状态,它是如何做到的?嗯,考虑一下 MSDN 的以下内容:
“线程使用局部存储内存机制来存储线程特定数据。公共语言运行时在创建每个进程时为其分配一个多槽数据存储数组。线程可以分配数据存储中的数据槽,在槽中存储和检索数据值,并在线程过期后释放该槽以供重用。数据槽对于每个线程都是唯一的。没有其他线程(甚至子线程)可以获取该数据。
如果命名槽不存在,则分配一个新槽。命名数据槽是公共的,任何人都可以操作。”
这就是其大致原理。让我们看看 MSDN 的例子(这里毫不避讳地抄袭)
using System;
using System.Threading;
namespace TLSDataSlot
{
class Program
{
static void Main()
{
Thread[] newThreads = new Thread[4];
for (int i = 0; i < newThreads.Length; i++)
{
newThreads[i] =
new Thread(new ThreadStart(Slot.SlotTest));
newThreads[i].Start();
}
}
}
class Slot
{
static Random randomGenerator = new Random();
public static void SlotTest()
{
// Set different data in each thread's data slot.
Thread.SetData(
Thread.GetNamedDataSlot("Random"),
randomGenerator.Next(1, 200));
// Write the data from each thread's data slot.
Console.WriteLine("Data in thread_{0}'s data slot: {1,3}",
AppDomain.GetCurrentThreadId().ToString(),
Thread.GetData(
Thread.GetNamedDataSlot("Random")).ToString());
// Allow other threads time to execute SetData to show
// that a thread's data slot is unique to the thread.
Thread.Sleep(1000);
Console.WriteLine("Data in thread_{0}'s data slot is still: {1,3}",
AppDomain.GetCurrentThreadId().ToString(),
Thread.GetData(
Thread.GetNamedDataSlot("Random")).ToString());
// Allow time for other threads to show their data,
// then demonstrate that any code a thread executes
// has access to the thread's named data slot.
Thread.Sleep(1000);
Other o = new Other();
o.ShowSlotData();
Console.ReadLine();
}
}
public class Other
{
public void ShowSlotData()
{
// This method has no access to the data in the Slot
// class, but when executed by a thread it can obtain
// the thread's data from a named slot.
Console.WriteLine(
"Other code displays data in thread_{0}'s data slot: {1,3}",
AppDomain.GetCurrentThreadId().ToString(),
Thread.GetData(
Thread.GetNamedDataSlot("Random")).ToString());
}
}
}
这可能会产生以下结果
可以看出,这使用了两样东西
- `GetNamedDataSlot`:查找命名槽
- `SetData`:在当前线程指定的槽中设置数据
还有另一种方法;我们也可以使用 `ThreadStaticAttribute`,这意味着该值对每个线程都是唯一的。让我们看看 MSDN 的例子(这里毫不避讳地抄袭)
using System;
using System.Threading;
namespace ThreadStatic
{
class Program
{
static void Main(string[] args)
{
for (int i = 0; i < 3; i++)
{
Thread newThread = new Thread(ThreadData.ThreadStaticDemo);
newThread.Start();
}
}
}
class ThreadData
{
[ThreadStaticAttribute]
static int threadSpecificData;
public static void ThreadStaticDemo()
{
// Store the managed thread id for each thread in the static
// variable.
threadSpecificData = Thread.CurrentThread.ManagedThreadId;
// Allow other threads time to execute the same code, to show
// that the static data is unique to each thread.
Thread.Sleep(1000);
// Display the static data.
Console.WriteLine("Data for managed thread {0}: {1}",
Thread.CurrentThread.ManagedThreadId, threadSpecificData);
}
}
}
这可能会产生以下输出
什么是 AppDomain
当我之前谈论进程时,我提到进程有物理隔离的内存和资源需要自我维护,并且我还提到进程至少有一个线程。微软还引入了一个额外的抽象/隔离层,称为 AppDomain。AppDomain 不是物理隔离,而是进程内的逻辑隔离。由于一个进程中可以存在多个 AppDomain,因此我们可以获得一些好处。例如,直到有了 AppDomain,需要访问彼此数据的进程必须使用代理,这会引入额外的代码和开销。通过使用 AppDomain,可以在同一进程中启动多个应用程序。与进程相同的隔离类型也适用于 AppDomain。线程可以在应用程序域之间执行,而无需进程间通信的开销。所有这些都封装在 `AppDomain` 类中。任何时候在应用程序中加载一个命名空间,它都会被加载到一个 AppDomain 中。除非另有说明,否则使用的 AppDomain 将与调用代码相同。AppDomain 可能包含线程,也可能不包含线程,这与进程不同。
为什么你应该使用 AppDomain
正如我在上面提到的,AppDomain 是一个更高级别的抽象/隔离,它们位于进程内部。那么为什么要使用 AppDomain 呢?本文的一位读者实际上给出了一个很好的例子。
“我以前需要为一个 Visual Studio 插件在一个单独的 AppDomain 中执行代码,该插件使用反射来查看当前项目的 DLL 文件。如果不单独在 AppDomain 中检查 DLL,开发者对项目所做的任何更改都不会显示在反射中,除非他们重新启动 Visual Studio。这正是 Marc 指出的原因:一旦 AppDomain 加载了一个程序集,它就无法被卸载。”
-- AppDomain 论坛帖子,作者 Daniel Flowers
所以我们可以看到,AppDomain 可以用于动态加载程序集,并且可以销毁整个 AppDomain 而不影响进程。我认为这说明了 AppDomain 提供的抽象/隔离。
NUnit 也采用了这种方法,但稍后会详细介绍。
设置 AppDomain 数据
让我们看一个关于如何处理 AppDomain 数据的示例
using System;
using System.Threading;
namespace AppDomainData
{
class Program
{
static void Main(string[] args)
{
Console.WriteLine("Fetching current Domain");
//use current AppDomain, and store some data
AppDomain domain = System.AppDomain.CurrentDomain;
Console.WriteLine("Setting AppDomain Data");
string name = "MyData";
string value = "Some data to store";
domain.SetData(name, value);
Console.WriteLine("Fetching Domain Data");
Console.WriteLine("The data found for key {0} is {1}",
name, domain.GetData(name));
Console.ReadLine();
}
}
}
这会产生一个相当不起眼的输出
那么,如何在特定的 `AppDomain` 中执行代码呢?让我们现在来看看。
using System;
using System.Threading;
namespace LoadNewAppDomain
{
class Program
{
static void Main(string[] args)
{
AppDomain domainA = AppDomain.CreateDomain("MyDomainA");
AppDomain domainB = AppDomain.CreateDomain("MyDomainB");
domainA.SetData("DomainKey", "Domain A value");
domainB.SetData("DomainKey", "Domain B value");
OutputCall();
domainA.DoCallBack(OutputCall); //CrossAppDomainDelegate call
domainB.DoCallBack(OutputCall); //CrossAppDomainDelegate call
Console.ReadLine();
}
public static void OutputCall()
{
AppDomain domain = AppDomain.CurrentDomain;
Console.WriteLine("the value {0} was found in {1}, running on thread Id {2}",
domain.GetData("DomainKey"),domain.FriendlyName,
Thread.CurrentThread.ManagedThreadId.ToString());
}
}
}
NUnit 和 AppDomain
自从我第一次发布这篇文章以来,已经有一些建议;其中似乎最受关注(至少对于本文内容而言)的是 NUnit 和 AppDomain,所以我想我应该解决这个问题。
下面是我在 NUnit 网站和一篇个人博客上找到的两个有趣的引述。
“使用 AppDomain 和影子复制动态重新加载程序集。当添加或更改测试时,这也适用。程序集将被重新加载,显示将自动更新。影子副本使用可在可执行文件(nunit-gui 和 nunit-console)的配置文件中指定的目录。”"
-- NUnit 版本说明页面
“NUnit 是由 .NET Framework 专家编写的。如果你查看 NUnit 的源代码,你会发现他们知道如何动态创建 AppDomain 并将程序集加载到这些域中。动态 AppDomain 为什么重要?动态 AppDomain 允许 NUnit 保持打开状态,同时允许你在不关闭 NUnit 的情况下编译、测试、修改、重新编译和重新测试代码。你可以这样做,因为 NUnit 会对你的程序集进行影子复制,将它们加载到一个动态域中,并使用文件监视器来查看你是否更改了它们。如果你确实更改了你的程序集,那么 NUnit 将销毁动态 AppDomain,重新复制文件,创建一个新的 AppDomain,然后就可以再次准备好了。”
本质上,NUnit 所做的是在一个单独的 AppDomain 中托管测试程序集。由于 AppDomains 是隔离的,它们可以在不影响其所属进程的情况下被卸载。
线程优先级
就像在现实生活中人类有优先级一样,线程也有。程序员可以为他们的线程设定优先级,但最终,接收者决定现在应该处理什么,什么可以等待。
Windows 使用 0-31 的优先级系统,其中 31 是最高优先级。任何高于 15 的优先级都需要通过管理员权限。优先级在 16-31 之间的线程被认为是实时线程,它们会抢占较低优先级的线程。可以想想驱动程序/输入设备以及类似的东西;它们将以 16-31 的优先级运行。
在 Windows 中,有一个调度系统(通常是轮循),每个优先级都有一个线程队列。**所有**最高优先级的线程都会获得一些 CPU 时间,然后是下一个级别(较低级别)的线程获得一些时间,依此类推。如果出现一个具有更高优先级的新线程,那么当前线程将被抢占,然后运行新的更高优先级级别的线程。只有在没有其他优先级队列中的更高优先级线程时,才会调度较低优先级的线程。
如果我们再次使用任务管理器,我们可以看到有可能更改一个进程以获得更高的优先级,这将使任何新生成的线程有更高的几率被调度(获得一些 CPU 时间)。
但我们在使用代码时也有选择,因为 `System.Threading.Thread` 类公开了一个 `Priority` 属性。如果我们看看 MSDN 的说法,我们可以设置以下值之一
线程可以被 assigned 以下任一优先级值
最高
高于正常
正常
低于正常
- 最低
注意:操作系统不要求必须遵守线程的优先级。
例如,操作系统可能会降低分配给高优先级线程的优先级,或者以其他方式动态调整优先级,以公平对待系统中的其他线程。因此,高优先级线程可能会被低优先级线程抢占。此外,大多数操作系统都有无限的调度延迟:系统中线程越多,操作系统调度线程执行所需的时间就越长。其中任何一个因素都可能导致高优先级线程错过截止日期,即使在快速的 CPU 上也是如此。
同样可以认为,以编程方式将用户创建的线程设置为 `Highest` 级别优先级。所以要小心,在设置线程优先级时要小心。
启动线程
启动新线程非常简单;我们只需要使用 `Thread` 构造函数之一,例如
Thread(ThreadStart)
Thread(ParameterizedThreadStart)
还有其他一些,但这是启动线程最常见的方式。让我们来看一个这两种方法的例子。
Thread workerThread = new Thread(StartThread);
Console.WriteLine("Main Thread Id {0}",
Thread.CurrentThread.ManagedThreadId.ToString());
workerThread.Start();
....
....
public static void StartThread()
{
for (int i = 0; i < 10; i++)
{
Console.WriteLine("Thread value {0} running on Thread Id {1}",
i.ToString(),
Thread.CurrentThread.ManagedThreadId.ToString());
}
}
//using parameter
Thread workerThread2 = new Thread(ParameterizedStartThread);
// the answer to life the universe and everything, 42 obviously
workerThread2.Start(42);
Console.ReadLine();
....
....
public static void ParameterizedStartThread(object value)
{
Console.WriteLine("Thread passed value {0} running on Thread Id {1}",
value.ToString(),
Thread.CurrentThread.ManagedThreadId.ToString());
}
将它们放在一起,我们可以看到一个包含主线程和两个工作线程的小程序。
using System;
using System.Threading;
namespace StartingThreads
{
class Program
{
static void Main(string[] args)
{
//no parameters
Thread workerThread = new Thread(StartThread);
Console.WriteLine("Main Thread Id {0}",
Thread.CurrentThread.ManagedThreadId.ToString());
workerThread.Start();
//using parameter
Thread workerThread2 = new Thread(ParameterizedStartThread);
// the answer to life the universe and everything, 42 obviously
workerThread2.Start(42);
Console.ReadLine();
}
public static void StartThread()
{
for (int i = 0; i < 10; i++)
{
Console.WriteLine("Thread value {0} running on Thread Id {1}",
i.ToString(),
Thread.CurrentThread.ManagedThreadId.ToString());
}
}
public static void ParameterizedStartThread(object value)
{
Console.WriteLine("Thread passed value {0} running on Thread Id {1}",
value.ToString(),
Thread.CurrentThread.ManagedThreadId.ToString());
}
}
}
可能会产生类似这样的结果
很简单,不是吗?
回调
现在您已经看到了一些创建线程的简单示例。
我们还没有看到线程之间同步的原理。
线程的运行顺序与应用程序的其余代码不同,所以你永远无法确定事件的确切顺序。也就是说,我们不能保证影响线程共享资源的动作会在另一个线程中的代码运行之前完成。
我们将在后续文章中详细探讨这个问题,但现在,让我们考虑一个使用 `Timer` 的简单示例。使用 `Timer`,我们可以指定在某个间隔调用一个方法,它可以检查某些数据状态后再继续。这是一个非常简单的模型;接下来的文章将展示更多关于更高级同步技术的细节,但现在,我们只使用 `Timer`。
让我们看一个非常小的例子。这个例子启动了一个工作线程和一个 `Timer`。主线程进入一个循环,等待一个已完成的标志设置为 true。`Timer` 等待工作线程发送“Completed”消息,然后通过将完成标志设置为 true 来允许被阻塞的主线程继续。
using System;
using System.Threading;
namespace CallBacks
{
class Program
{
private string message;
private static Timer timer;
private static bool complete;
static void Main(string[] args)
{
Program p = new Program();
Thread workerThread = new Thread(p.DoSomeWork);
workerThread.Start();
//create timer with callback
TimerCallback timerCallBack =
new TimerCallback(p.GetState);
timer = new Timer(timerCallBack, null,
TimeSpan.Zero, TimeSpan.FromSeconds(2));
//wait for worker to complete
do
{
//simply wait, do nothing
} while (!complete);
Console.WriteLine("exiting main thread");
Console.ReadLine();
}
public void GetState(Object state)
{
//not done so return
if (message == string.Empty) return;
Console.WriteLine("Worker is {0}", message);
//is other thread completed yet, if so signal main
//thread to stop waiting
if (message == "Completed")
{
timer.Dispose();
complete = true;
}
}
public void DoSomeWork()
{
message = "processing";
//simulate doing some work
Thread.Sleep(3000);
message = "Completed";
}
}
}
这可能会产生类似这样的结果
我们完成了
好了,这就是我这次想说的。线程是一个复杂的主题,因此,本系列文章可能会有些艰深,但我认为值得一读。
下次
下次我们将讨论线程生命周期/线程机会/陷阱。
如果您喜欢这篇文章,能否请您为它投票,这样我就可以知道我即将开始的这个线程专题是否值得继续创作文章。
非常感谢您。
历史
- v1.1: 2008/05/19: 添加了关于 AppDomain/NUnit 使用 AppDomain 的原因,以及关于线程优先级的补充内容。
- v1.0: 2008/05/18: 初次发布。