65.9K
CodeProject 正在变化。 阅读更多。
Home

多线程解密

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.77/5 (45投票s)

2011年6月16日

CPOL

22分钟阅读

viewsIcon

79405

downloadIcon

2278

.NET 中的多线程 - 逐步讲解。

引言

本文通过一个工作代码示例,解释了在 .NET 中实现多线程应用程序背后的概念。本文简要介绍了以下主题:

  1. 线程概念
  2. 如何在 .NET 中实现多线程
  3. 实现线程安全应用程序背后的概念
  4. 死锁

什么是进程?

进程是可执行文件运行的操作系统上下文。它用于隔离虚拟地址空间、线程、对象句柄(指向文件等资源的指针)和环境变量。进程具有基本优先级类和最大内存消耗等属性。

含义……

  1. 进程是包含资源的内存切片
  2. 操作系统执行的独立任务
  3. 正在运行的应用程序
  4. 一个进程拥有一个或多个操作系统线程

从技术上讲,进程是4GB的连续内存空间。此内存是安全和私有的,其他进程无法访问。

什么是线程?

线程是进程内执行的指令流。所有线程都在一个进程内执行,一个进程可以有多个线程。一个进程的所有线程都使用其进程的虚拟地址空间。线程是操作系统调度的单位。当操作系统在线程之间切换执行时,线程的上下文会被保存/恢复。

含义……

  • 线程是进程内执行的指令流。
  • 所有线程都在一个进程内执行,一个进程可以有多个线程。
  • 一个进程的所有线程都使用其进程的虚拟地址空间。

什么是多线程?

多线程是指一个进程同时有多个线程处于活动状态。这允许通过时间片轮转实现同时线程执行的“表象”,或在超线程和多处理器系统上实现实际的同时线程执行。

多线程 - 为什么和为什么不

为什么要多线程

  • 保持UI响应。
  • 提高性能(例如,CPU密集型和I/O密集型活动的并发操作)。

为什么不使用多线程

  • 开销可能降低实际性能。
  • 使代码复杂化,增加设计时间,并增加出现 bug 的风险。

线程池

线程池为您的应用程序提供了一个由系统管理的工人线程池。托管线程池中的线程是后台线程。所有前台线程退出后,ThreadPool 线程不会使应用程序保持运行。每个进程有一个线程池。线程池的默认大小是每个可用处理器25个线程。线程池中的线程数量可以通过 SetMaxThreads 方法更改。每个线程都使用默认堆栈大小并以默认优先级运行。

.NET中的线程

在 .NET 中,线程通过以下方法实现:

  1. 线程类
  2. 委托(Delegates)
  3. 后台工作者
  4. 线程池
  5. 任务
  6. 并行

在下面的章节中,我们将了解如何通过这些方法实现线程。

简而言之,多线程是一种技术,通过它可以使任何应用程序并发运行多个任务,从而最大限度地利用处理器的计算能力并保持 UI 响应。一个例子可以用下面的框图来表示:

代码

该项目是一个简单的 WinForms 应用程序,它通过三种方法演示了 .NET 中线程的使用。

  1. 委托(Delegates)
  2. 线程类
  3. 后台工作者

该应用程序异步执行一个耗时操作,从而不阻塞 UI。同样的耗时操作通过上述三种方式实现,以演示其目的。

“繁重”操作

在现实世界中,一个繁重的操作可以是任何事情,从轮询数据库到流媒体文件。在这个例子中,我们通过将值附加到字符串来模拟一个繁重的操作。字符串是不可变的,因此字符串附加会创建一个新的字符串变量并丢弃旧的字符串变量(这由 CLR 处理)。如果执行次数过多,这会消耗大量资源(这就是我们使用 Stringbuilder.Append 的原因)。在上面的 UI 屏幕中,设置计数器来指定字符串附加的次数。

我们在后端有一个 Utility 类,它有一个 LoadData() 方法。它还有一个与 LoadData() 签名类似的委托。

class Utility
{
    public delegate string delLoadData(int number);
    public static delLoadData dLoadData;

    public Utility()
    {
        
    }

    public static string LoadData(int max)
    {
        string str = string.Empty;

        for (int i = 0; i < max; i++)
                                {
            str += i.ToString();
                                }

        return str;
    }
}

同步调用

当您点击“Get Data Sync”按钮时,操作在与UI线程相同的线程中运行(阻塞调用)。因此,在操作运行期间,UI将保持无响应。

private void btnSync_Click(object sender, EventArgs e)
{
    this.Cursor = Cursors.WaitCursor;
    this.txtContents.Text = Utility.LoadData(upCount);
    this.Cursor = Cursors.Default;
}

异步调用

使用委托(异步编程模型)

如果您选择“委托”单选按钮,则会使用委托异步调用 LoadData() 方法。我们首先使用 utility.LoadData() 的地址初始化类型 delLoadData。然后我们调用委托的 BeginInvoke() 方法。在 .NET 世界中,任何名称为 BeginXXXEndXXX 的方法都是异步的。例如,delegate.Invoke() 将在同一线程中调用方法。而 delegate.BeginInvoke() 将在单独的线程中调用方法。

BeginInvoke() 接受三个参数:

  1. 要传递给 Utility.LoadData() 方法的参数
  2. 回调方法的地址
  3. 对象状态
Utility.dLoadData = new Utility.delLoadData(Utility.LoadData);
Utility.dLoadData.BeginInvoke(upCount, CallBack, null);
回调

一旦我们在一个线程中启动一个操作,我们必须知道该操作中发生了什么。换句话说,当它完成操作时,我们应该得到通知。有三种方法可以知道操作是否已完成:

  1. 回调
  2. 轮询
  3. 等待直到完成

在我们的项目中,我们使用回调方法来捕获线程的结束。这正是您在调用 Begininvoke() 方法时传递的方法名称。它告诉线程在完成其应做的工作后返回并调用该方法。

一旦一个方法在单独的线程中触发,你可能对该方法的返回值感兴趣,也可能不感兴趣。如果该方法不返回任何内容,那么它将是一个“即发即弃”的调用。在这种情况下,你不会对回调感兴趣,并将回调参数传递为 null

Utility.dLoadData.BeginInvoke(upCount, CallBack, null);

在我们的例子中,我们需要一个回调方法,因此我们传递了我们的回调方法的名称,巧合的是 CallBack()

private void CallBack(IAsyncResult asyncResult)
{
    string result= string.Empty;

    if (this.cancelled)
        result = "Operation Cancelled";
    else
        result = Utility.dLoadData.EndInvoke(asyncResult);
    
      object[] args = { this.cancelled, result };
    this.BeginInvoke(dUpdateUI, args);
}

回调方法的签名为 – void MethodName(IAsyncResult asyncResult)

IAsyncResult 包含有关线程的必要信息。返回的数据可以按如下方式捕获:

result = Utility.dLoadData.EndInvoke(asyncResult);

轮询方法(本项目未使用)如下所示:

IAsyncResult r = Utility.dLoadData.BeginInvoke(upCount, CallBack, null);
while (!r.IsCompleted)
{
    //do work
}
result = Utility.dLoadData.EndInvoke(asyncResult);

等待完成,顾名思义,就是等待操作完成。

IAsyncResult r = Utility.dLoadData.BeginInvoke(upCount, CallBack, null);

//do work
result = Utility.dLoadData.EndInvoke(asyncResult);
更新UI

现在我们已经捕获了操作的结束并检索了 LoadData() 返回的结果,我们需要使用该结果更新 UI。但是有一个问题。需要更新的文本框位于 UI 线程中,结果已在回调中返回。回调发生在它开始的同一线程中。因此,UI 线程与回调线程不同。换句话说,文本框**不能**像下面显示的那样用结果更新。

this.txtContents.Text = text;

在回调方法中执行此行将导致跨线程系统异常。我们必须在 UI 线程和后台线程之间建立桥梁,以更新文本框中的结果。这可以通过表单的 Invoke()BeginInvoke() 方法完成。

我定义了一个将更新 UI 的方法

private void UpdateUI(bool cancelled, string text)
{
    this.btnAsync.Enabled = true;
    this.btnCancel.Enabled = false;
    this.txtContents.Text = text;
}

为上述方法定义一个委托

private delegate void delUpdateUI(bool value, string text);
dUpdateUI = new delUpdateUI(UpdateUI);

调用表单的 BeginInvoke() 方法

object[] args = { this.cancelled, result };
this.BeginInvoke(dUpdateUI, args);

这里需要注意的是,一旦使用委托生成了一个线程,它就不能被取消、暂停或中止。我们对该线程没有控制权。

使用 Thread 类

同样的操作可以使用 Thread 类实现。优点是 Thread 类为您提供了更多控制操作暂停和取消的能力。Thread 类位于 System.Threading 命名空间中。

我们有一个私有方法 LoadData(),它是对我们的 Utility.LoadData() 的包装。

private void LoadData()
{
    string result = Utility.LoadData(upCount);
    object[] args = { this.cancelled, result };
    this.BeginInvoke(dUpdateUI, args);
}

我们这样做是因为 Utility.LoadData() 需要一个参数。我们需要一个线程启动委托来初始化线程。

doWork = new Thread(new ThreadStart(this.LoadData));
doWork.Start();

委托具有 void, void 签名。如果我们需要传递参数,我们必须使用参数化线程启动委托。不幸的是,参数化线程启动委托只能将 object 作为参数。我们需要一个 string,并且必须实现类型转换。

doWork = new Thread(new ParameterizedThreadStart(this.LoadData));
doWork.Start(parameter);

Thread 类提供了对线程的很多控制,如 Suspend、Abort、Interrupt、ThreadState 等。

使用 BackgroundWorker

BackgroundWorker 是一个控件,有助于简化线程操作。BackgroundWorker 的主要功能是它可以异步报告进度,这可用于更新状态栏,以可视化的方式让 UI 了解操作的进度。

为此,我们需要将以下属性设置为 true。这些属性默认为 false

  • WorkerReportsProgress
  • WorkerSupportsCancel

该控件有三个主要事件:DoCountProgressChangedRunWorkerCompleted。我们需要在初始化时注册这些事件。

this.bgCount.DoWork += new DoWorkEventHandler(bgCount_DoWork);
this.bgCount.ProgressChanged += 
     new ProgressChangedEventHandler(bgCount_ProgressChanged);
this.bgCount.RunWorkerCompleted += 
     new RunWorkerCompletedEventHandler(bgCount_RunWorkerCompleted);

操作可以通过调用 RunWorkerAsync() 方法开始,如下所示:

this.bgCount.RunWorkerAsync();

一旦调用,将调用以下方法来处理操作

void bgCount_DoWork(object sender, DoWorkEventArgs e)
{
    string result = string.Empty;
    if (this.bgCount.CancellationPending)
    {
        e.Cancel = true;
        e.Result = "Operation Cancelled";
    }
    else
    {
        for (int i = 0; i < this.upCount; i++)
        {
            result += i.ToString();
            this.bgCount.ReportProgress((i / this.upCount) * 100);
        }
        e.Result = result;
    }
}

可以检查 CancellationPending 属性以查看操作是否已取消。可以通过调用以下方法取消操作:

this.bgCount.CancelAsync();

下面一行报告进度百分比:

this.bgCount.ReportProgress((i / this.upCount) * 100);

一旦调用,将调用以下方法来更新 UI:

void bgCount_ProgressChanged(object sender, ProgressChangedEventArgs e)
{
    if (this.bgCount.CancellationPending)
        this.txtContents.Text = "Cancelling....";
    else
        this.progressBar.Value = e.ProgressPercentage;
}

最后,调用 bgCount_RunWorkerCompleted 方法来完成操作。

void bgCount_RunWorkerCompleted(object sender, RunWorkerCompletedEventArgs e)
{
    this.btnAsync.Enabled = true;
    this.btnCancel.Enabled = false;
    this.txtContents.Text = e.Result.ToString();
} 

线程池

不建议程序员自行创建尽可能多的线程。创建线程是一个开销很大的操作。内存和计算方面都存在开销。此外,计算机每个CPU在给定时间只能处理一个线程。因此,如果单核系统上有多个线程,计算机一次只能处理一个线程。它通过为每个线程分配时间“切片”并以轮询方式(这也取决于它们的优先级)处理可用线程来实现这一点。这被称为上下文切换,其本身是另一个开销。因此,如果我们有太多线程实际上什么也没做或处于空闲状态,我们只会面临内存消耗、上下文切换等方面的开销,而没有任何净收益。因此,作为开发人员,我们在创建线程时需要极其谨慎,并勤奋地处理我们正在使用的现有线程数量。

幸运的是,CLR 有一个托管代码库可以为我们完成这项工作。这就是 ThreadPool 类。这个类管理其池中的多个线程,并根据我们的应用程序需求决定是否创建或销毁任何线程。线程池最初没有线程。随着请求开始排队,它开始创建线程。如果我们设置 SetMinThreads 属性,线程池会随着工作项开始排队而迅速分配那么多线程。当线程池发现线程长时间空闲或休眠时,它会酌情决定终止线程。

因此,线程池是利用计算机维护的后台线程池的绝佳方式。ThreadPool 类允许我们将工作项排队,然后将其委托给后台线程。

WaitCallback threadCallback = new WaitCallback(HeavyOperation);

for (int i = 0; i < 3; i++)
{
  System.Threading.ThreadPool.QueueUserWorkItem(HeavyOperation, i);			 
}

繁重操作定义为:

private static void HeavyOperation(object WorkItem)
{
  System.Threading.Thread.Sleep(5000);
  Console.WriteLine("Executed work Item {0}", (int)WorkItem);
} 

请注意 WaitCallBack 委托的签名。它必须将一个对象作为方法参数。这通常用于在线程之间传递状态信息。

既然我们知道了如何使用 ThreadPool 将工作委托给后台线程,那么我们必须探索与其配套的回调技术。我们通过使用 WaitHandle 来捕获回调。WaitHandle 类继承了两个子类 - AutoResetEventManualResetEvent

public static void Demo_ResetEvent()
{  
  Server s = new Server();
  ThreadPool.QueueUserWorkItem(new WaitCallback((o) =>
  {
     s.DoWork();                

   }));

   ((AutoResetEvent)Global.GetHandle(Handles.AutoResetEvent)).WaitOne();
    Console.WriteLine("Work complete signal received");
} 

这里我们有一个 Global 类,它为 WaitHandles 维护一个单例实例。

public static class Global
{
  static WaitHandle w = null;
  static AutoResetEvent ae = new AutoResetEvent(false);
  static ManualResetEvent me = new ManualResetEvent(false);
  public static WaitHandle GetHandle(Handles Type)
  {            
    switch (Type)
    {                
      case Handles.ManualResetEvent:                    
         w = me;
         break;
      case Handles.AutoResetEvent:                    
         w = ae;                    
         break;
      default:
         break;
    }
    return w;
  }
}  

WaitOne 方法会阻塞代码执行,直到后台线程在 WaitHandle 上设置了值。

public void DoWork()
{            
  Console.WriteLine("Work Starting ...");
  Thread.Sleep(5000);
  Console.WriteLine("Work Ended ...");
  ((AutoResetEvent)Global.GetHandle(Handles.AutoResetEvent)).Set();
} 

AutoResetEvent 在设置后会自动重置。它类似于高速公路上的收费站,两个或更多车道汇合,以便车辆一次只能通过一辆。当车辆靠近时,闸门被设置,允许它通过,然后立即自动重置以供下一辆车通过。

下面的例子详细说明了 AutoResetEvent。假设我们有一个服务器,其中包含一个 DoWork() 方法。此方法是一个繁重操作,应用程序需要在调用此方法后更新日志文件。假设有多个线程异步访问此方法。因此,我们必须确保更新日志是线程安全的,或者一次只对一个线程可用。

public void DoWork(int threadID, int waitSingal)
{ 
  Thread.Sleep(waitSingal);
  Console.WriteLine("Work Complete by Thread : {0} @ {1}", threadID, DateTime.Now.ToString("hh:mm:ss"));
  ((AutoResetEvent)Global.GetHandle(Handles.AutoResetEvent)).Set();

} 
public void UpdateLog(int threadID)
{
  if(((AutoResetEvent)Global.GetHandle(Handles.AutoResetEvent)).WaitOne(5000))
       Console.WriteLine("Update Log File by thread : {0} @ {1}", threadID, DateTime.Now.ToString("hh:mm:ss"));
  else
       Console.WriteLine("Time out");
}

我们创建两个线程并同时委托 DoWork() 方法。然后我们调用 UpdateLog()。更新日志处的代码执行将等待每个线程完成各自的任务,然后才进行更新。

public static void Demo_AutoResetEvent()
{
  Console.WriteLine("Demo Autoreset event...");
  Server s = new Server();

  Console.WriteLine("Start Thread 1..");
  ThreadPool.QueueUserWorkItem(new WaitCallback((o) =>
  {
     s.DoWork(1, 4000);  
                
  }));            

  Console.WriteLine("Start Thread 2..");
  ThreadPool.QueueUserWorkItem(new WaitCallback((o) =>
  {
     s.DoWork(2, 4000);                
                
  }));

  s.UpdateLog(1);
  s.UpdateLog(2);
} 

ManualResetEventAutoResetEvent 的不同之处在于,我们需要在再次设置它之前手动重置它。与 AutoResetEvent 不同,它不会自动重置。假设我们有一个服务器,它在后台线程中持续发送消息。服务器运行一个连续循环,等待发送消息的信号。当值设置时,服务器开始发送消息。当等待句柄重置时,服务器停止,并且该过程可以重复。

public void SendMessages(bool monitorSingal)
{            
  int counter=1;
  while (monitorSingal)
  {
     if (((ManualResetEvent)Global.GetHandle(Handles.ManualResetEvent)).WaitOne())
     {
        Console.WriteLine("Sending message {0}", counter);
        Thread.Sleep(3000);
        counter += 1;
     }
  }           
} 
public static void Demo_ManualResetEvent()
{
  Console.WriteLine("Demo Mnaulreset event...");
  Server s = new Server();
  ThreadPool.QueueUserWorkItem(new WaitCallback((o) =>
  {
    s.SendMessages(true);
  }));

  Console.WriteLine("Press 1 to send messages");
  Console.WriteLine("Prress 2 to stop messages");

  while (true)
  {               
    int input = Convert.ToInt16(Console.ReadLine());                              

    switch (input)
    {
      case 1:
         Console.WriteLine("Starting to send message ...");                        
         ((ManualResetEvent)Global.GetHandle(Handles.ManualResetEvent)).Set();
         break;
      case 2:                                                
         ((ManualResetEvent)Global.GetHandle(Handles.ManualResetEvent)).Reset();
         Console.WriteLine("Message Stopped ..."); 
         break;
      default:
         Console.WriteLine("Invalid Input");
         break;
    }
  }            
} 

任务类 

.NET 4.0 通过 Task 类的形式对 ThreadPool 类进行了扩展。概念基本保持不变,不同之处在于我们可以取消任务、等待任务并随时检查线程的状态以查看进度。考虑以下示例,其中我们有三个方法:

static void DoHeavyWork(CancellationToken ct)
{
 try
 {
                while (true)
                {
                    ct.ThrowIfCancellationRequested();
                    Console.WriteLine("Background thread working for task 3..");
                    Thread.Sleep(2000);
                    if (ct.IsCancellationRequested)
                    {
                        ct.ThrowIfCancellationRequested();
                    }
                }
            }
            catch (OperationCanceledException ex)
            {
                Console.WriteLine("Exception :" + ex.Message);
            }
            catch (Exception ex)
            {
                Console.WriteLine("Exception :", ex.Message);
            }            
            
        }

static void DoHeavyWork(int n)
{
  Thread.Sleep(5000);
  Console.WriteLine("Operation complete for thread {0}", Thread.CurrentThread.ManagedThreadId);
}
static int DoHeavyWorkWithResult(int num)
{
  Thread.Sleep(5000);
  Console.WriteLine("Operation complete for thread {0}", Thread.CurrentThread.ManagedThreadId);
  return num;
}

我们有 3 个任务,旨在运行这 3 个方法。第一个线程在不返回结果的情况下完成。第二个线程完成并返回结果,而第三个线程在完成之前被取消。

 try
            {
                Console.WriteLine(DateTime.Now);
                CancellationTokenSource cts1 = new CancellationTokenSource();
                CancellationTokenSource cts2 = new CancellationTokenSource();
                CancellationTokenSource cts3 = new CancellationTokenSource();

                Task t1 = new Task((o) => DoHeavyWork(2), cts1.Token);

                Console.WriteLine("Starting Task 1");
                Console.WriteLine("Thread1 state {0}", t1.Status);
                t1.Start();

                Console.WriteLine("Starting Task 2");
                Task<int> t2 = Task<int>.Factory.StartNew((o) => DoHeavyWorkWithResult(2), cts2.Token);

                Console.WriteLine("Starting Task 3");
                Task t3 = new Task((o) => DoHeavyWork(cts3.Token), cts3);
                t3.Start();               

                Console.WriteLine("Thread1 state {0}", t1.Status);
                Console.WriteLine("Thread2 state {0}", t2.Status);
                Console.WriteLine("Thread3 state {0}", t3.Status);
                   
                // wait for task 1 to be over
                t1.Wait();

                Console.WriteLine("Task 1 complete");

                Console.WriteLine("Thread1 state {0}", t1.Status);
                Console.WriteLine("Thread2 state {0}", t2.Status);
                Console.WriteLine("Thread3 state {0}", t3.Status);

                //cancel task 3
                Console.WriteLine("Task 3 is : {0} and cancelling...", t3.Status);
                cts3.Cancel();

                // wait for task 2 to be over
                t2.Wait();

                Console.WriteLine("Task 2 complete");

                Console.WriteLine("Thread1 state {0}", t1.Status);
                Console.WriteLine("Thread2 state {0}", t2.Status);
                Console.WriteLine("Thread3 state {0}", t3.Status);

                Console.WriteLine("Result {0}", t2.Result);
                Console.WriteLine(DateTime.Now);

                t3.Wait();

                Console.WriteLine("Task 3 complete");
                Console.WriteLine(DateTime.Now);
            }
            
            catch (Exception ex)
            {
                Console.WriteLine("Exception : " + ex.Message.ToString());
            }
            finally
            {
                Console.Read();
            }             

.NET 4.0 中的并行编程(时间片) 

.NET 4.0 带有一个很酷的并行处理功能。我们上面看到的大多数线程示例都只是将大批量作业委托给空闲线程。计算机仍然以轮询方式一次处理一个线程。简而言之,我们并没有真正意义上的多任务处理。所有这些都可以通过 Parallel 类实现。

假设你有一个 Employee 类,它有一个繁重的操作 ProcessEmployeeInformation

class Employee
{
  public Employee(){}

  public int EmployeeID {get;set;}

  public void ProcessEmployeeInformation()
  {
    Thread.Sleep(5000);
    Console.WriteLine("Processed Information for Employee {0}",EmployeeID);
  }
} 

我们创建 8 个实例并发出并行请求。在 4 核处理器上,将同时处理 4 个请求,其余请求将排队等待任何线程空闲。   

 List<employee> empList = new List<employee>()
 {
   new Employee(){EmployeeID=1},
   new Employee(){EmployeeID=2},
   new Employee(){EmployeeID=3},
   new Employee(){EmployeeID=4},
   new Employee(){EmployeeID=5},
   new Employee(){EmployeeID=6},
   new Employee(){EmployeeID=7},
   new Employee(){EmployeeID=8},
 };

 Console.WriteLine("Start Operation {0}", DateTime.Now);
 System.Threading.Tasks.Parallel.ForEach(empList, (e) =>e.ProcessEmployeeInformation());

</employee></employee>

我们可以通过使用 MaxDegreeOfParallelism 属性来控制或限制并发任务的数量。如果将其设置为 -1,则没有限制。

System.Threading.Tasks.Parallel.For(0, 8, new ParallelOptions() { MaxDegreeOfParallelism = 4 }, (o) =>
       {
          Thread.Sleep(5000);
          Console.WriteLine("Thread ID - {0}", Thread.CurrentThread.ManagedThreadId);
        });

并行处理的问题在于,如果我们发出了一组请求,我们无法保证响应会保持相同的顺序。线程处理的顺序是不确定的。AsOrdered 属性可以帮助我们确保这一点。输入可以以任何顺序处理,但输出将以该顺序交付。

Console.WriteLine("Start Operation {0}", DateTime.Now);
var q = from e in empList.AsParallel().AsOrdered()
        select new { ID = e.EmployeeID };

foreach (var item in q)
{
  Console.WriteLine(item.ID);
}
Console.WriteLine("End Operation {0}", DateTime.Now);

Web 应用程序

ASP.NET Web 应用程序中的线程可以通过从客户端向服务器发送 AJAX 请求来实现。这使得客户端能够向服务器请求某些数据而不会阻塞 UI。当数据准备好时,客户端通过回调收到通知,并且只更新客户端相关部分,使客户端灵活且响应迅速。 ASP.NET Web 应用程序中的线程可以通过从客户端向服务器发送 AJAX 请求来实现。这使得客户端能够向服务器请求某些数据而不会阻塞 UI。当数据准备好时,客户端通过回调收到通知,并且只更新客户端相关部分,使客户端灵活且响应迅速。实现此目的最常见的方法是使用 ICallbackEventHandler。请参阅项目 Demo.Threading.Web。我有一个与 Windows 相同的界面,带有一个文本框用于输入数字,一个文本框用于显示数据。“加载数据”按钮执行前面讨论的“繁重”操作。
<div>
    <asp:Label runat="server" >Enter Number</asp:Label>
    <input type="text" id="inputText" /><br /><br />
    <asp:TextBox ID="txtContentText" runat="server" TextMode="MultiLine" /><br /><br />
    <input type="button" id="LoadData" title="LoadData" 
           onclick="LoadHeavyData()" value="LoadData" />
</div>
我有一个 JavaScript 函数 LoadHeavyData(),它在按钮的点击事件中被调用。此函数调用带有参数的 CallServer 函数。
<script type="text/ecmascript">
    function LoadHeavyData() {

        var lb = document.getElementById("inputText");
        CallServer(lb.value.toString(), "");
    }

    function ReceiveServerData(rValue) {
        document.getElementById("txtContentText").innerHTML = rValue;
    }
</script>
在页面加载事件定义的脚本中,CallServer 函数注册到服务器
protected void Page_Load(object sender, EventArgs e)
{
    String cbReference = Page.ClientScript.GetCallbackEventReference(this, 
                         "arg", "ReceiveServerData", "context");
    
    String callbackScript;
    callbackScript = "function CallServer(arg, context)" + 
                     "{ " + cbReference + ";}";
    
    Page.ClientScript.RegisterClientScriptBlock(this.GetType(),
                      "CallServer", callbackScript, true);
}
上述脚本定义并注册了一个 CallServer 函数。调用 CallServer 函数时,将调用 ICallbackeventHandler 的 RaiseCallBackEvent。此方法调用 LoadData() 方法,该方法执行繁重操作并返回数据。
public void RaiseCallbackEvent(string eventArgument)
{
    if (eventArgument!=null)
    {
        Result = this.LoadData(Convert.ToUInt16(eventArgument));
    }
}

private string LoadData(int num)
{
    // call Heavy data
    return Utility.LoadData(num);
}
一旦 LoadData() 执行完毕,就会执行 ICallbackEventHandlerGetCallbackResult() 方法,该方法返回数据。
public string GetCallbackResult()
{
    return Result;
}
最后,调用 ReceiveServerData() 函数来更新 UI。ReceiveServerData 函数在页面加载事件中注册为 CallServer() 函数的回调。
function ReceiveServerData(rValue) {
    document.getElementById("txtContentText").innerHTML = rValue;
}

WPF

通常 WPF 应用程序以两个线程启动 -

  1. 渲染线程 - 在后台运行,处理低级任务。
  2. UI 线程 - 接收输入,处理事件,绘制屏幕并运行应用程序代码。

WPF 中的线程实现方式与 WinForms 相同,不同之处在于我们使用 Dispatcher 对象来桥接后台线程的 UI 更新。UI 线程将工作项排队到一个名为 Dispatcher 的对象中。Dispatcher 根据优先级选择工作项并逐一运行直到完成。每个 UI 线程都有一个 Dispatcher,每个 Dispatcher 可以在一个线程中执行项目。当后台线程完成一项昂贵的工作并且 UI 需要用结果更新时,我们使用 Dispatcher 将该项目排队到 UI 线程的任务列表中。

考虑以下示例,我们有一个网格,分为两部分。在第一部分中,我们有一个名为 ViewModelProperty 的属性绑定到视图模型,在第二部分中,我们有一个绑定的集合 ViewModelCollection。我们还有一个按钮可以更新这些属性。为了模拟“繁重工作”,我们在更新属性之前让线程休眠。

<DockPanel>
    <TextBlock Text="View Model Proeprty: " DockPanel.Dock="Left"/>
    <TextBlock Text="{Binding ViewModelProperty}" DockPanel.Dock="Right"/>
</DockPanel>
<ListBox Grid.Row="1" ItemsSource="{Binding ViewModelCollection}"/>
<Button Grid.Row="2" Content="Change Property" Width="100" Command="{Binding ChangePropertyCommand}"/>  

这是视图模型。请注意通过后台线程调用的方法 DoWork()。如前所述,我们有两个属性 - ViewModelPropertyViewModelCollection。这些属性实现了 INotifyCollectionChanged,并且视图模型本身继承自 DispatcherObject。本示例的主要目的是展示如何将后台线程的数据更改传递给 UI。在 DoWork() 方法中,属性 ViewModelProperty 的更改会自动处理,但集合的添加通过 Dispatcher 对象从后台线程排队到 UI 线程。这里需要注意的关键是,虽然 WPF 运行时负责处理后台线程的属性更改通知,但集合更改的通知必须由程序员处理。

public ViewModel()
        {
            ChangePropertyCommand = new MVVMCommand((o) => DoWork(), (o)=> DoWorkCanExecute());
            ViewModelCollection = new ObservableCollection<string>();
            ViewModelCollection.CollectionChanged += 
                new System.Collections.Specialized.NotifyCollectionChangedEventHandler(ViewModelCollection_CollectionChanged);
        }
                
        public ICommand ChangePropertyCommand { get; set; }

        private string viewModelProperty;
        public string ViewModelProperty
        {
            get { return viewModelProperty; }
            set
            {
                if (value!=viewModelProperty)
                {
                    viewModelProperty = value;
                    OnPropertyChanged("ViewModelProperty");
                }
            }
        }

        private ObservableCollection<string> viewModelCollection;
        public ObservableCollection<string> ViewModelCollection
        {
            get { return viewModelCollection; }
            set
            {
                if (value!= viewModelCollection)
                {
                    viewModelCollection = value;
                }
            }

        }

        public void DoWork()
        {
            ThreadPool.QueueUserWorkItem((o) =>
                {
                    Thread.Sleep(5000);
                    ViewModelProperty = "New VM Property";
                    Dispatcher.Invoke(DispatcherPriority.Background,
                        (SendOrPostCallback)delegate
                        {
                            ViewModelCollection.Add("New Collection Item");
                        },null);                    
                });            
        }

        private bool DoWorkCanExecute()
        {
            return true;
        }

        public event PropertyChangedEventHandler PropertyChanged;

        private void OnPropertyChanged(string PropertyName)
        {
            if (PropertyChanged!=null)
            {
                PropertyChanged(this, new PropertyChangedEventArgs(PropertyName));
            }
        }

    } 

线程安全

关于线程的讨论,如果没有涉及线程安全的概念,那将是不完整的。考虑一个资源被多个线程使用的情况。这意味着该资源在多个线程的控制下被使用和共享。这将导致资源以不确定的方式行为,结果也会变得混乱。这就是为什么我们需要实现“线程安全”应用程序,以便资源在任何给定时间只能被一个线程访问。以下是在 .NET 中实现线程安全的方法:
  • Interlocked – Interlocked 类将操作视为原子操作。例如,简单的加法、减法操作是处理器内部的三步操作。当多个线程访问受这些操作影响的同一资源时,结果可能会变得混乱,因为一个线程在执行前两步后可能会被抢占。另一个线程然后可以执行所有三步。当第一个线程恢复执行时,它会覆盖实例变量中的值,并且第二个线程执行的操作的效果会丢失。因此,我们需要使用 Interlocked 类,它将这些操作视为原子操作,使其线程安全。例如:Increment、Decrement、Add、Read、Exchange、CompareExchange。
    System.Threading.Interlocked.Increment(object);
  • Monitor – Monitor 类用于锁定可能容易受到多个线程同时访问该对象危害的对象。
    if (Monitor.TryEnter(this, 300)) {
        try {
            // code protected by the Monitor here.
        }
        finally {
            Monitor.Exit(this);
        }
    }
    else {
        // Code if the attempt times out.
    }
  •   - Lock 类是 Monitor 的增强版本。换句话说,它封装了 Monitor 的功能,而无需像 Monitor 那样显式退出。最常见的例子是 Singleton 类的 GetInstance() 方法。在这里,该方法可以被同时访问它的各种模块使用。通过使用对象 syncLock 锁定该代码块来实现线程安全。请注意,用于锁定的对象类似于现实世界中锁的钥匙。如果两个或多个资源拥有钥匙,它们都可以打开锁并访问底层资源。因此,我们需要确保钥匙(或者在本例中是对象)永远不能共享。最好将该对象作为类的私有成员。
  • static object syncLock = new object();
    
    if (_instance == null)
    {
        lock (syncLock)
        {
            if (_instance == null)
            {
                _instance = new LoadBalancer();
            }
        }
    } 
  • 读写锁 - 该锁可以被无限数量的并发读取者获取,或者由单个写入者独占获取。如果大多数访问是读取操作,而写入操作不频繁且持续时间短,则这可以提供比 Monitor 更好的性能。在任何时候,读取者和写入者分别排队。当写入线程拥有锁时,读取者排队等待写入者完成。当读取者拥有锁时,所有写入线程分别排队。读取者和写入者交替完成工作。下面的代码详细解释了这一点。我们有两个方法 - ReadFromCollection WriteToCollection 分别用于从集合中读取和写入。请注意方法 -AcquireReaderLockAcquireWriterLock 的使用。这些方法会一直持有线程,直到读取者或写入者空闲。
    static void Main(string[] args)
            {
                // Thread 1 writing
                new Thread(new ThreadStart(() =>
                    {
                        WriteToCollection(new int[]{1,2,3});
                        
                    })).Start();
    
                // Thread 2 Reading
                new Thread(new ThreadStart(() =>
                {
                    ReadFromCollection();                
                })).Start();
    
                // Thread 3 Writing
                new Thread(new ThreadStart(() =>
                {
                    WriteToCollection(new int[] { 4, 5, 6 });
    
                })).Start();
    
                // Thread 4 Reading
                new Thread(new ThreadStart(() =>
                {
                    ReadFromCollection();
                })).Start();            
    
                Console.ReadLine();
            }
    
            static void ReadFromCollection()
            {
                rwLock.AcquireReaderLock(5000);
                try 
                {
                    Console.WriteLine("Read Lock acquired by thread : {0}  @ {1}", Thread.CurrentThread.ManagedThreadId, DateTime.Now.ToString("hh:mm:ss"));
                    Console.Write("Collection : ");
                    foreach (int item in myCollection)
                    {
                        Console.Write(item + ", ");
                    }
                    Console.Write("\n");
                }
                catch (Exception ex)
                {
                    Console.WriteLine("Exception : " + ex.Message);
                }
                finally
                {
                    Console.WriteLine("Read Lock released by thread : {0}  @ {1}", Thread.CurrentThread.ManagedThreadId, DateTime.Now.ToString("hh:mm:ss"));
                    rwLock.ReleaseReaderLock();
                    
                }
            }
    
            static void WriteToCollection(int[] num)
            {
                rwLock.AcquireWriterLock(5000);
                try
                {
                    Console.WriteLine("Write Lock acquired by thread : {0}  @ {1}", Thread.CurrentThread.ManagedThreadId, DateTime.Now.ToString("hh:mm:ss"));
                    myCollection.AddRange(num);
                    Console.WriteLine("Written to collection ............: {0}", DateTime.Now.ToString("hh:mm:ss"));
                }
                catch (Exception ex)
                {
                    Console.WriteLine("Exception : " + ex.Message);
                }
                finally
                {
                    Console.WriteLine("Write Lock released by thread : {0}  @ {1}", Thread.CurrentThread.ManagedThreadId, DateTime.Now.ToString("hh:mm:ss"));
                    rwLock.ReleaseWriterLock();                
                }
            }   
  • 互斥体 (Mutex) - 互斥体用于在操作系统之间共享资源。一个很好的例子是检测同一个应用程序的多个版本是否同时运行。

还有其他实现线程安全的方法。请参阅 MSDN 了解更多信息。
死锁

关于如何创建线程安全应用程序的讨论,如果没有触及死锁的概念,那将永远不会完整。让我们看看那是什么。

死锁是指两个或多个线程锁定同一资源,每个线程都等待另一个线程释放资源的情况。这种情况会导致操作无限期地停滞。通过仔细编程可以避免死锁。示例:
  • 线程 A 锁定对象 A
  • 线程 A 锁定对象 B
  • 线程 B 锁定对象 B
  • 线程 B 锁定对象 A

线程 A 等待线程 B 释放对象 B,线程 B 等待线程 A 释放对象 A。考虑下面的例子,我们有一个 DeadLock 类。我们有两个方法,其中包含两个对象的嵌套锁定 - OperationAOperationB。当我们同时启动两个线程运行操作 A 和操作 B 时,就会出现死锁情况。

public class DeadLock
{        
 static object lockA = new object();
 static object lockB = new object();

 public void OperationA()
 {            
  lock (lockA)
  {
   Console.WriteLine("Thread {0} has locked Obect A", Thread.CurrentThread.ManagedThreadId);
   lock (lockB)
   {
    Console.WriteLine("Thread {0} has locked Obect B", Thread.CurrentThread.ManagedThreadId);
   }
   Console.WriteLine("Thread {0} has released Obect B", Thread.CurrentThread.ManagedThreadId);
  }
  Console.WriteLine("Thread {0} has released Obect A", Thread.CurrentThread.ManagedThreadId);
 }

 public void OperationB()
 {            
  lock (lockB)
  {
   Console.WriteLine("Thread {0} has locked Obect B", Thread.CurrentThread.ManagedThreadId);
   lock (lockA)
   {
    Console.WriteLine("Thread {0} has locked Obect A", Thread.CurrentThread.ManagedThreadId);
   }
   Console.WriteLine("Thread {0} has released Obect A", Thread.CurrentThread.ManagedThreadId);
  }
  Console.WriteLine("Thread {0} has released Obect B", Thread.CurrentThread.ManagedThreadId); 
 } }   
 DeadLock deadLock = new DeadLock();

 Thread tA = new Thread(new ThreadStart(deadLock.OperationA));
 Thread tB = new Thread(new ThreadStart(deadLock.OperationB));

 Console.WriteLine("Starting Thread A");
 tA.Start();
                
 Console.WriteLine("Starting Thread B");
 tB.Start();

工作线程与I/O线程

操作系统只有一个线程概念,它用它来运行各种进程。但 .NET CLR 为我们抽象出了一层,我们可以处理两种类型的线程:工作线程和 I/O 线程。方法 ThreadPool.GetAvailableThreads(out workerThread, out ioThread) 显示了每种线程的可用数量。在编码时,应用程序中的繁重任务应分为两类:计算密集型操作或 I/O 密集型操作。计算密集型操作是指 CPU 用于繁重计算(如运行搜索结果或复杂算法)的操作。I/O 密集型操作是指利用系统 I/O 硬件或网络驱动器的操作。例如:读取和写入文件、从数据库获取数据或查询远程 Web 服务器。计算密集型操作应委托给工作线程,I/O 密集型操作应委托给 I/O 线程。当我们在 ThreadPool 中排队项目时,我们正在将项目委托给工作线程。如果我们使用工作线程执行 I/O 密集型操作,则在设备驱动程序执行该操作时,线程会保持阻塞。阻塞的线程是浪费的资源。另一方面,如果我们将相同的任务委托给 I/O 线程,则调用线程会将任务委托给处理设备驱动程序的操作系统部分,并返回到线程池。当操作完成时,线程池中的一个线程将收到通知并处理任务完成。优点是线程保持不阻塞以处理其他任务,因为当 I/O 操作启动时,调用线程只将任务委托给处理设备驱动程序的操作系统部分。线程没有理由一直阻塞直到任务完成。在 .NET 类库中,某些类型的异步编程模型处理 I/O 线程。例如:FileStream 类中的 BeginRead()EndRead()。根据经验,所有带有 BeginXXXEndXXX 的方法都属于这一类。

摘要 

“能力越大,责任越大”——线程池 

  1. 任何应用程序都不应在 UI 线程上运行繁重任务。没有什么比冻结的 UI 更难看的了。应尽可能使用线程池异步管理繁重工作来创建线程。
  2. UI无法直接从非UI或后台线程更新数据。程序员需要将该工作委托给UI线程。这可以通过winform类的Invoke方法、WPF中的Dispatcher或在使用BackGroundWorker时自动处理来完成。
  3. 线程是昂贵的资源,应受到尊重。“越多越好……”这种说法不幸不适用。
  4. 仅仅将任务分配给另一个线程并不能解决我们应用程序中的问题。没有奇迹发生,我们需要仔细考虑我们的设计和目的,以最大限度地提高效率。
  5. 使用 Thread 类创建线程应谨慎处理。在可能的情况下,应使用线程池。随意调整线程的优先级也不是一个好主意,因为它可能会阻止其他重要线程的执行。
  6. 粗心大意地将 IsBackground 属性设置为 false 可能会产生灾难性影响。前台线程不会让应用程序终止,直到其任务完成。因此,如果用户想要终止应用程序,并且后台有一个被标记为前台线程的任务正在运行,那么应用程序在任务完成之前不会终止。
  7. 当多个线程在应用程序中共享资源时,应仔细实现线程同步技术。应通过仔细编码避免死锁。应始终避免嵌套锁,因为这可能导致死锁。
  8. 程序员应确保我们不会创建比所需更多的线程。空闲线程只会增加开销,并可能导致“内存不足”异常。
  9. I/O 操作必须委托给 I/O 线程,而不是工作线程。

© . All rights reserved.