.NET 多线程编程初学者指南:第 2 部分






4.91/5 (254投票s)
.NET 多线程编程初学者指南。
引言
我很害怕地说,除非我在做事情,否则我会感到无聊。所以现在我终于觉得我已经学会了 WPF 的基础知识,是时候把我的注意力转向其他事情了。
我有一长串需要我关注的事情,比如 WCF/WF/CLR via C# 版本 2 的书,但我最近去面试了一个(并得到了,但最终拒绝了)需要我了解很多多线程知识的职位。虽然我认为自己对多线程相当擅长,但我想,是的,我对多线程还不错,但总可以做得更好。因此,我决定致力于撰写一系列关于 .NET 多线程的文章。本系列无疑将大量借鉴我购买的一本出色的 Visual Basic .NET 多线程手册,它很好地填补了我和你们的 MSDN 空白。
我怀疑这个主题将从简单到中等再到高级,它将涵盖 MSDN 中的很多内容,但我希望也能加入我自己的见解。
我不知道确切的时间表,但它最终可能会是这样的:
- .NET 多线程介绍(上一篇文章)
- 线程生命周期/多线程机会/陷阱(本文)
- 同步
- 线程池
- UI 中的多线程(WinForms / WPF / Silverlight)
- 多线程的未来(任务并行库)
我想最好的方式就是直接开始。不过在开始之前有一点需要注意,我将使用 C# 和 Visual Studio 2008。
我将在本文中尝试涵盖的内容是:
线程生命周期
下图说明了最常见的线程状态,以及当线程进入每种状态时会发生什么
以下是所有可用线程状态的列表:
状态 | 描述 |
运行 |
线程已启动,未被阻塞,并且没有待处理的 ThreadAbortException 。 |
StopRequested |
正在请求线程停止。这仅供内部使用。 |
SuspendRequested |
正在请求线程挂起。 |
背景 |
线程作为后台线程执行,而不是前台线程。此状态通过设置 Thread.IsBackground 属性来控制。 |
Unstarted |
尚未在线程上调用 Thread.Start 方法。 |
Stopped |
线程已停止。 |
WaitSleepJoin |
线程被阻塞。这可能是调用 (我们将在第 3 部分介绍所有这些锁定/同步技术。) |
Suspended |
线程已被挂起。 |
AbortRequested |
已在线程上调用 Thread.Abort 方法,但线程尚未收到将尝试终止它的待处理 System.Threading.ThreadAbortException 。 |
Aborted |
线程状态包含 AbortRequested 且线程现已死亡,但其状态尚未更改为 Stopped 。 |
更详细地探讨其中一些内容
在本节中,我将包含一些代码,这些代码将探讨上面提到的一些多线程区域。我不会涵盖所有这些,但我会尝试涵盖其中的大部分。
连接线程
Join
方法(不带任何参数)会阻塞调用线程,直到当前线程终止。需要注意的是,如果当前线程不终止,调用者将无限期阻塞。如果在调用 Join
方法时线程已经终止,该方法会立即返回。
Join
方法有一个重载,允许您设置等待线程完成的毫秒数。如果在计时器到期时线程尚未完成,Join
将退出并将控制权返回给调用线程(而连接的线程继续执行)。
此方法将调用线程的状态更改为包含 WaitSleepJoin
(根据 MSDN 文档)。
如果一个线程依赖于另一个线程,此方法非常有用。
让我们看一个小的例子(附带的演示 ThreadJoin 项目)。
在这个小例子中,我们有两个线程;我希望第一个线程先运行,第二个线程在第一个线程完成后运行。
using System;
using System.Threading;
namespace ThreadJoin
{
class Program
{
public static Thread T1;
public static Thread T2;
public static void Main(string[] args)
{
T1 = new Thread(new ThreadStart(First));
T2 = new Thread(new ThreadStart(Second));
T1.Name = "T1";
T2.Name = "T2";
T1.Start();
T2.Start();
Console.ReadLine();
}
//thread T1 threadStart
private static void First()
{
for (int i = 0; i < 5; i++)
{
Console.WriteLine(
"T1 state [{0}], T1 showing {1}",
T1.ThreadState, i.ToString());
}
}
//thread T2 threadStart
private static void Second()
{
//what the state of both threads
Console.WriteLine(
"T2 state [{0}] just about to Join, T1 state [{1}], CurrentThreadName={2}",
T2.ThreadState, T1.ThreadState,
Thread.CurrentThread.Name);
//join T1
T1.Join();
Console.WriteLine(
"T2 state [{0}] T2 just joined T1, T1 state [{1}], CurrentThreadName={2}",
T2.ThreadState, T1.ThreadState,
Thread.CurrentThread.Name);
for (int i = 5; i < 10; i++)
{
Console.WriteLine(
"T2 state [{0}], T1 state [{1}], CurrentThreadName={2} showing {3}",
T2.ThreadState, T1.ThreadState,
Thread.CurrentThread.Name, i.ToString());
}
Console.WriteLine(
"T2 state [{0}], T1 state [{1}], CurrentThreadName={2}",
T2.ThreadState, T1.ThreadState,
Thread.CurrentThread.Name);
}
}
}
这是这个小程序的输出,我们可以清楚地看到线程 T1 完成,然后线程 T2 的操作运行。
注意:线程 T1 继续运行然后停止,然后运行线程 T2 指定的操作。
Sleep
Thread
类上的静态 Thread.Sleep
方法相当简单;它只是将当前线程暂停指定时间。考虑以下示例,其中启动了两个线程,它们运行两个独立的计数器方法;第一个线程 (T1) 从 0-50 计数,第二个线程 (T2) 从 51-100 计数。
线程 T1 到 10 时会休眠 1 秒,线程 T2 到 70 时会休眠 5 秒。
让我们看一个小的例子(附带的演示 ThreadSleep 项目)
using System;
using System.Threading;
namespace ThreadSleep
{
class Program
{
public static Thread T1;
public static Thread T2;
public static void Main(string[] args)
{
Console.WriteLine("Enter Main method");
T1 = new Thread(new ThreadStart(Count1));
T2 = new Thread(new ThreadStart(Count2));
T1.Start();
T2.Start();
Console.WriteLine("Exit Main method");
Console.ReadLine();
}
//thread T1 threadStart
private static void Count1()
{
Console.WriteLine("Enter T1 counter");
for (int i = 0; i < 50; i++)
{
Console.Write(i + " ");
if (i == 10)
Thread.Sleep(1000);
}
Console.WriteLine("Exit T1 counter");
}
//thread T2 threadStart
private static void Count2()
{
Console.WriteLine("Enter T2 counter");
for (int i = 51; i < 100; i++)
{
Console.Write(i + " ");
if (i == 70)
Thread.Sleep(5000);
}
Console.WriteLine("Exit T2 counter");
}
}
}
输出可能如下:
在这个例子中,线程 T1 先运行,所以它开始计数(我们稍后会看到,T1 不一定是第一个开始的线程),计数到 10,此时 T1 休眠 1 秒,并进入 WaitSleepJoin
状态。此时,T2 运行,开始计数,计数到 70,然后进入休眠状态(并进入 WaitSleepJoin
状态),此时 T1 被唤醒并运行完成。然后 T2 被唤醒并能够完成(因为 T1 已经完成,只剩下 T2 的工作要做)。
中断
当一个线程被置于休眠状态时,它会进入 WaitSleepJoin
状态。如果线程处于此状态,可以使用 Interrupt
方法将其放回调度队列。当线程处于 WaitSleepJoin
状态时调用 Interrupt
会导致抛出 ThreadInterruptedException
,因此任何编写的代码都需要捕获此异常。
如果此线程当前未被阻塞在等待、休眠或连接状态,则它将在下次开始阻塞时被中断。
让我们看一个小的例子(附带的演示 ThreadInterrupt 项目)
using System;
using System.Threading;
namespace ThreadInterrupt
{
class Program
{
public static Thread sleeper;
public static Thread waker;
public static void Main(string[] args)
{
Console.WriteLine("Enter Main method");
sleeper = new Thread(new ThreadStart(PutThreadToSleep));
waker = new Thread(new ThreadStart(WakeThread));
sleeper.Start();
waker.Start();
Console.WriteLine("Exiting Main method");
Console.ReadLine();
}
//thread sleeper threadStart
private static void PutThreadToSleep()
{
for (int i = 0; i < 50; i++)
{
Console.Write(i + " ");
if (i == 10 || i == 20 || i == 30)
{
try
{
Console.WriteLine("Sleep, Going to sleep at {0}",
i.ToString());
Thread.Sleep(20);
}
catch (ThreadInterruptedException e)
{
Console.WriteLine("Forcibly ");
}
Console.WriteLine("woken");
}
}
}
//thread waker threadStart
private static void WakeThread()
{
for (int i = 51; i < 100; i++)
{
Console.Write(i + " ");
if (sleeper.ThreadState == ThreadState.WaitSleepJoin)
{
Console.WriteLine("Interrupting sleeper");
sleeper.Interrupt();
}
}
}
}
}
这可能会产生以下输出:
从这个输出可以看出,休眠线程正常启动,当它达到 10 时,被置于休眠状态,因此进入 WaitSleepJoin
状态。然后唤醒线程启动并立即尝试 Interrupt
休眠线程(它处于 WaitSleepJoin
状态,因此抛出并捕获了 ThreadInterruptedException
)。然而,随着最初的休眠线程的休眠时间过去,它再次被允许运行直到完成。
我个人很少需要使用 Interrupt
方法,但我确实认为中断线程相当危险,因为你无法保证线程在哪里。
“随意中断线程是危险的,因为调用堆栈中的任何框架或第三方方法都可能意外地收到中断,而不是您预期的代码。它只需要线程短暂地阻塞在一个简单的锁或同步资源上,任何挂起的中断就会生效。如果该方法不是为了被中断而设计的(在 finally
块中没有适当的清理代码),对象可能会处于不可用状态,或者资源未完全释放。
当您确切知道线程的位置时,中断线程是安全的。”
-- C# 中的多线程,Joseph Albahari。
Pause
以前有一种使用 Pause()
方法暂停线程的方法。但现在已弃用,因此您必须使用替代方法,例如 WaitHandles。为了演示这一点,有一个组合应用程序涵盖了后台线程的暂停/恢复和中止。
恢复
以前有一种使用 Resume()
方法暂停线程的方法。但现在已弃用,因此您必须使用替代方法,例如 WaitHandles。为了演示这一点,有一个组合应用程序涵盖了后台线程的暂停/恢复和中止。
Abort
首先,让我声明有一个 Abort()
方法,但这并不是你应该使用的方法(在我看来,根本不应该使用)。我只想首先引用两个信誉良好的来源关于使用 Abort()
方法的危险性。
“一个被阻塞的线程也可以通过其 Abort
方法强制释放。这与调用 Interrupt
具有类似的效果,只是抛出的是 ThreadAbortException
而不是 ThreadInterruptedException
。此外,除非在 catch
块中调用 Thread.ResetAbort
,否则该异常将在 catch
块结束时重新抛出(以彻底终止线程)。在此期间,线程的 ThreadState
为 AbortRequested
。
然而,Interrupt
和 Abort
之间最大的区别在于当它们在未阻塞的线程上调用时会发生什么。Interrupt
会等到线程下次阻塞时才执行任何操作,而 Abort
会在线程正在执行的地方(甚至可能不在您的代码中)直接抛出异常。中止未阻塞的线程可能会产生重大后果。”
-- C# 中的多线程,Joseph Albahari。
“一旦启动了一些并发工作,一个常见的问题是如何停止它?以下是想要停止正在进行的一些工作的两个常见原因:
你需要关闭程序。用户取消了操作。在第一种情况下,通常可以接受在执行过程中放弃一切,而不必干净地关闭,因为程序的内部状态不再重要,并且操作系统将在程序退出时释放我们程序持有的许多资源。唯一需要关注的是程序是否持久存储状态——确保在程序退出时任何此类状态都是一致的非常重要。但是,如果我们依赖数据库来存储此类状态,我们通常仍然可以放弃正在进行的操作,特别是如果我们使用事务——中止事务会将所有内容回滚到事务开始之前的状态,因此这应该足以使系统恢复到一致状态。
当然,有些情况下,放弃一切是行不通的。如果应用程序在没有数据库帮助的情况下将其状态存储在磁盘上,它将需要采取措施确保磁盘上的表示在放弃操作之前是一致的。在某些情况下,程序可能正在与外部系统或服务进行交互,需要超出自动发生的显式清理。然而,如果您将系统设计为在突然故障(例如,断电)面前具有鲁棒性,那么简单地放弃正在进行的工作而不是在关闭程序时整齐地清理应该是可以接受的。(事实上,有一种观点认为,如果您的程序需要显式关闭,那么它的鲁棒性就不够——对于一个真正鲁棒的程序,突然终止应该始终是安全的关闭方式。鉴于此,有人说,您不妨将此作为您的正常关闭模式——这是一种非常快速的关闭方式!)
然而,用户启动的单个操作的取消是完全不同的事情。
如果用户出于某种原因选择取消操作(也许它花费的时间太长),她将希望之后能够继续使用程序。因此,简单地放弃一切是不可接受的,因为操作系统不会替我们清理。我们的程序必须在操作取消后继续使用其内部状态。因此,取消操作必须以有序的方式完成,以便一旦操作完成,程序的内部状态仍然一致。
考虑到这一点,请考虑使用 Thread.Abort
。不幸的是,这是取消工作的流行选择,因为它通常能够阻止目标线程,无论它在做什么。这意味着您经常会在邮件列表和新闻组中看到它被推荐为停止正在进行的工作的一种方式,但它实际上只适用于您正在关闭程序的过程中,因为它使得很难确定程序之后将处于什么状态。”
—— 如何在 .NET 中停止线程(以及为什么 Thread.Abort 是邪恶的),Ian Griffiths。
考虑到所有这些,我创建了一个小型应用程序,我认为它是一个行为良好的工作线程,允许用户执行一些后台工作,并安全轻松地暂停/恢复和取消它。这并不是唯一的方法,但它是一种方法。
让我们看一个小例子(附带的演示 ThreadResumePause_StopUsingEventArgs 项目)。
不幸的是,我不得不在这里包含一些 UI 代码,以允许用户点击不同的按钮进行暂停/恢复等,但我将只包含我认为与解释主题相关的 UI 代码部分。
所以首先,这里是工作线程类;需要注意的重要一点是 volatile
关键字的使用。
volatile
关键字表示字段可以由程序中的某些内容(例如操作系统、硬件或并发执行的线程)修改。
系统始终在请求时读取 volatile 对象的当前值,即使前一个指令请求的是同一对象的值。此外,对象的值在赋值时会立即写入。
volatile
修饰符通常用于多个线程在不使用 lock
语句序列化访问的情况下访问的字段。使用 volatile
修饰符可确保线程检索由另一个线程写入的最新值。
using System;
using System.ComponentModel;
using System.Threading;
namespace ThreadResumePause_StopUsingEventArgs
{
public delegate void ReportWorkDoneEventhandler(object sender,
WorkDoneCancelEventArgs e);
/// <summary>
/// This class provides a background worker that finds prime numbers, that
/// are reported to the UI via the ReportWorkDone event. The UI may pause
/// the worker by calling the Pause() method, and may resume the worker by
/// calling the Resume() method. The UI may also cancel the worker by setting
/// the ReportWorkDone events event args Cancel property to true.
/// </summary>
public class WorkerThread
{
private Thread worker;
public event ReportWorkDoneEventhandler ReportWorkDone;
private volatile bool cancel = false;
private ManualResetEvent trigger = new ManualResetEvent(true);
//ctor
public WorkerThread()
{
}
//Do the work, start the thread
public void Start(long primeNumberLoopToFind)
{
worker = new Thread(new ParameterizedThreadStart(DoWork));
worker.Start(primeNumberLoopToFind);
}
//Thread start method
private void DoWork(object data)
{
long primeNumberLoopToFind = (long)data;
int divisorsFound = 0;
int startDivisor = 1;
for (int i = 0; i < primeNumberLoopToFind; i++)
{
//wait for trigger
trigger.WaitOne();
divisorsFound = 0;
startDivisor = 1;
//check for prime numbers, and if we find one raise
//the ReportWorkDone event
while (startDivisor <= i)
{
if (i % startDivisor == 0)
divisorsFound++;
startDivisor++;
}
if (divisorsFound == 2)
{
WorkDoneCancelEventArgs e =
new WorkDoneCancelEventArgs(i);
OnReportWorkDone(e);
cancel = e.Cancel;
//check whether thread should carry on,
//perhaps user cancelled it
if (cancel)
return;
}
}
}
/// <summary>
/// make the worker thread wait on the ManualResetEvent
/// </summary>
public void Pause()
{
trigger.Reset();
}
/// <summary>
/// signal the worker thread, raise signal on
/// the ManualResetEvent
/// </summary>
public void Resume()
{
trigger.Set();
}
/// <summary>
/// Raise the ReportWorkDone event
/// </summary>
protected virtual void OnReportWorkDone(WorkDoneCancelEventArgs e)
{
if (ReportWorkDone != null)
{
ReportWorkDone(this, e);
}
}
}
//Simple cancellable EventArgs, that also exposes
//current prime number found to UI
public class WorkDoneCancelEventArgs : CancelEventArgs
{
public int PrimeFound { get; private set; }
public WorkDoneCancelEventArgs(int primeFound)
{
this.PrimeFound = primeFound;
}
}
}
这是 UI 代码的相关部分 (WinForms, C#)。请注意,我没有检查在执行 Invoke
之前是否实际需要 Invoke
。
MSDN 对 Control.InvokeRequired
属性的描述如下:
获取一个值,该值指示当调用方对控件进行方法调用时,是否必须调用 Invoke 方法,因为调用方与创建控件的线程不同。
Windows 窗体中的控件绑定到特定线程,并且不是线程安全的。因此,如果您从不同的线程调用控件的方法,则必须使用控件的 Invoke
方法之一将调用调度到正确的线程。此属性可用于确定是否必须调用 Invoke 方法,如果您不知道哪个线程拥有控件,这会很有用。
所以可以使用它来确定是否确实需要 Invoke。调用 InvokeRequired/Invoke/BeginInvoke/EndInvoke
都是线程安全的。
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Data;
using System.Drawing;
using System.Linq;
using System.Text;
using System.Windows.Forms;
using System.Threading;
namespace ThreadResumePause_StopUsingEventArgs
{
public partial class Form1 : Form
{
private WorkerThread wt = new WorkerThread();
private SynchronizationContext context;
private bool primeThreadCancel = false;
public Form1()
{
InitializeComponent();
//obtain the current SynchronizationContext
context = SynchronizationContext.Current;
}
void wt_ReportWorkDone(object sender, WorkDoneCancelEventArgs e)
{
//+++++++++++++++++++++++++++++++++++++++++++++++++++++
//NOTE : This would also work to marshal call to UI thread
//+++++++++++++++++++++++++++++++++++++++++++++++++++++
//this.Invoke(new EventHandler(delegate
//{
// lstItems.Items.Add(e.PrimeFound.ToString());
//}));
//marshal call to UI thread
context.Post(new SendOrPostCallback(delegate(object state)
{
this.lstItems.Items.Add(e.PrimeFound.ToString());
}), null);
//should worker thread be caneclled, has user clicked cancel button?
e.Cancel = primeThreadCancel;
}
private void btnStart_Click(object sender, EventArgs e)
{
//start the worker and listen to its ReportWorkDone event
wt.Start(100000);
wt.ReportWorkDone +=
new ReportWorkDoneEventhandler(wt_ReportWorkDone);
primeThreadCancel= false;
}
private void btnCancel_Click(object sender, EventArgs e)
{
primeThreadCancel= true;
}
private void btnPause_Click(object sender, EventArgs e)
{
wt.Pause();
}
private void btnResume_Click(object sender, EventArgs e)
{
wt.Resume();
}
}
}
运行后,它看起来像这样:
那么这一切是如何运作的呢?这里有一些我希望直到第 4 部分才涉及到的东西,但它们根本无法避免,所以我会尽量简单地介绍它们。这里有几个关键概念,例如:
- 使用输入参数启动工作线程
- 将工作线程的输出调度到 UI 线程
- 暂停工作线程
- 恢复工作线程
- 取消工作线程
我将依次解释这些部分。
使用输入参数启动工作线程
这很容易通过使用 ParameterizedThreadStart
实现,您只需启动线程,传入一个输入参数,例如 worker.Start(primeNumberLoopToFind)
,然后在实际的 private void DoWork(object data)
方法中,您可以通过使用 data
参数获取参数值,例如 long primeNumberLoopToFind = (long)data
。
将工作线程的输出调度到 UI 线程
工作线程引发 ReportWorkDone
事件供 UI 使用,但是当 UI 尝试使用 ReportWorkDone EventArg
对象属性将项目添加到 UI 拥有的 ListBox
控件时,您将收到跨线程违规,除非您采取措施将线程调度到 UI 线程。这被称为线程亲和性;创建 UI 控件的线程拥有这些控件,因此对 UI 控件的任何调用都必须通过 UI 线程进行。
有几种方法可以做到这一点;我正在使用 .NET 2.0 版本,它利用了一个名为 SynchronizationContext
的类,我在 Form 的构造函数中获取它。然后,我可以自由地将工作线程的结果调度到 UI 线程,以便可以将它们添加到 UI 的控件中。操作如下:
context.Post(new SendOrPostCallback(delegate(object state)
{
this.lstItems.Items.Add(e.PrimeFound.ToString());
}), null);
暂停工作线程
为了暂停工作线程,我使用了一个名为 ManualResetEvent
的线程对象,它可以根据 ManualResetEvent
的信号状态来使线程等待和恢复其操作。基本上,在信号状态下,等待 ManualResetEvent
的线程将被允许继续,而在非信号状态下,等待 ManualResetEvent
的线程将被强制等待。我们现在将检查 WorkerThread
类的相关部分。
我们声明一个新的 ManualResetEvent
,它以信号状态启动。
private ManualResetEvent trigger = new ManualResetEvent(true);
然后我们尝试在 workerThread DoWork
方法中等待信号状态。由于 ManualResetEvent
以信号状态启动,线程会继续运行。
for (int i = 0; i< primeNumberLoopToFind; i++)
{
//wait for trigger
trigger.WaitOne();
....
....
因此,对于暂停,我们所需要做的就是将 ManualResetEvent
置于非信号状态(使用 Reset
方法),这会导致工作线程等待 ManualResetEvent
再次置于信号状态。
trigger.Reset();
恢复工作线程
恢复很容易;我们所需要做的就是将 ManualResetEvent
置于信号状态(使用 Set
方法),这会导致工作线程不再等待 ManualResetEvent
,因为它再次处于信号状态。
trigger.Set();
取消工作线程
如果你读过我上面引用的 Ian Griffith 的文章,你会知道他只是建议尽可能简单,但使用了对 UI 和工作线程都可见的布尔标志。我也这样做了,但我使用了 CancelEventArgs
,它允许用户直接在 CancelEventArgs
中为工作线程设置取消状态,这样工作线程就可以使用它来判断是否应该取消。它像这样工作:
- 工作线程已启动
- 工作线程引发
WorkDone
事件,带CancelEventArgs
- 如果用户点击取消按钮,则
CancelEventArgs
的 cancel 被设置为 true - 工作线程发现
CancelEventArgs
的 cancel 已设置,因此中断其工作 - 由于工作线程没有更多工作要做,它就终止了
我只是觉得这比使用 Abort()
方法更安全一些。
多线程机会
有一些非常明显的多线程机会,如下:
后台执行顺序
如果一个任务可以在后台成功运行,那么它就是多线程的候选。例如,设想一个需要搜索数千个项目以查找匹配项的搜索——这将是一个出色的后台线程选择。
外部资源
另一个例子可能是当您使用外部资源(例如数据库/Web 服务/远程文件系统)时,访问这些资源可能会带来性能损失。通过对这些类型的访问进行线程化,您可以减轻在单个线程中访问这些资源所产生的一些开销。
UI 响应性
我们可以想象我们有一个用户界面(UI),允许用户执行各种任务。其中一些任务可能需要很长时间才能完成。在实际情境中,假设该应用程序是一个电子邮件客户端应用程序,允许用户创建/获取电子邮件。获取电子邮件可能需要一段时间才能完成,因为获取电子邮件必须与邮件服务器交互才能获取当前用户的电子邮件。对获取电子邮件的代码进行线程化将有助于保持 UI 对进一步的用户交互的响应性。如果我们在 UI 中不对长时间运行的任务进行线程化,而仅仅依赖主线程,我们很容易陷入 UI 响应性很差的情况。因此,这是多线程的主要候选。正如我们将在后续文章中看到的,存在线程亲和性问题,在处理 UI 时需要考虑,但我会将该讨论留到后续文章中。
Socket 编程
如果您曾经进行过任何套接字编程,您可能需要创建一个能够接受客户端的服务器。典型的安排可能是一个聊天应用程序,其中服务器能够接受 n 个客户端,并能够从客户端读取和向客户端写入。这主要通过线程实现。尽管我意识到 .NET 中有一个异步套接字 API 可用,但您可以选择使用它而不是手动创建线程。套接字仍然是一个有效的线程示例。
我见过最好的例子位于 这个链接。使用套接字的基本思想是您有一个服务器和 n 个客户端。服务器运行(主线程活动),然后对于每个客户端连接请求,都会创建一个新线程来处理客户端。在客户端,通常客户端应该能够接收来自其他客户端(通过服务器)的消息,并且客户端还应该允许客户端用户输入消息。
让我们只考虑客户端一分钟。客户端能够向其他客户端发送消息(通过服务器),这意味着需要有一个线程能够响应用户输入的数据。客户端还应该能够显示来自其他客户端(通过服务器)的消息,这意味着这也需要在一个线程上。如果我们使用同一个线程来监听来自其他客户端的传入消息,我们将阻塞输入新数据以发送给其他客户端的能力。
我不想在这个例子上多费口舌,因为它不是本文的主要驱动力,但我认为它值得一谈,只是为了让您了解开始使用线程时会遇到什么样的问题,以及它们实际上是如何有帮助的。
陷阱
在本节中,我将讨论一些在处理线程时常见的陷阱。这绝不是所有的陷阱,而是一些最常见的错误。
执行顺序
如果我们考虑以下代码示例(附带的演示 ThreadTrap1 项目)
using System;
using System.Threading;
namespace ThreadTrap1
{
/// <summary>
/// This example shows a threading Trap, you simply can't
/// rely on threads executing in the order in which they
/// are started.
/// </summary>
class Program
{
static void Main(string[] args)
{
Thread T1 = new Thread(new ThreadStart(Increment));
Thread T2 = new Thread(new ThreadStart(Increment));
T1.Name = "T1";
T2.Name = "T2";
T1.Start();
T2.Start();
Console.ReadLine();
}
private static void Increment()
{
for (int i = 0; i < 100000;i++ )
if (i % 10000 == 0)
Console.WriteLine("Thread Name {0}",
Thread.CurrentThread.Name);
WriteDone(Thread.CurrentThread.Name);
}
private static void WriteDone(string threadName)
{
switch (threadName)
{
case "T1" :
Console.WriteLine("T1 Finished");
break;
case "T2":
Console.WriteLine("T2 Finished");
break;
}
}
}
}
从这段代码来看,人们会认为名为 T1 的线程总是会首先完成,因为它是第一个启动的。然而,情况并非如此;它有时会首先完成,有时则不会。请看下面从同一代码的两次不同运行中截取的两张屏幕截图。
在此屏幕截图中,T1 确实首先完成
在此屏幕截图中,T2 首先完成
所以这是一个陷阱;永远不要假设线程会按照你启动它们的顺序运行。
执行顺序 / 非同步代码
考虑以下代码示例(附带的演示 ThreadTrap2 项目)
using System;
using System.Threading;
namespace ThreadTrap2
{
/// <summary>
/// This example shows a threading Trap, you simply can't
/// rely on threads executing in the order in which they
/// are started. And also what happens when access to a
/// shared field in not synchronized
/// </summary>
class Program
{
protected static long sharedField = 0;
static void Main(string[] args)
{
Thread T1 = new Thread(new ThreadStart(Increment));
Thread T2 = new Thread(new ThreadStart(Increment));
T1.Name = "T1";
T2.Name = "T2";
T1.Start();
T2.Start();
Console.ReadLine();
}
private static void Increment()
{
for (int i = 0; i < 100000; i++)
if (i % 10000 == 0)
Console.WriteLine("Thread Name {0}, Shared value ={1}",
Thread.CurrentThread.Name, sharedField.ToString());
sharedField++;
WriteDone(Thread.CurrentThread.Name);
}
private static void WriteDone(string threadName)
{
switch (threadName)
{
case "T1":
Console.WriteLine("T1 Finished, Shared value ={0}",
sharedField.ToString());
break;
case "T2":
Console.WriteLine("T2 Finished, Shared value ={0}",
sharedField.ToString());
break;
}
}
}
}
这段代码与前面的例子类似;我们仍然不能依赖线程的执行顺序。这次情况还更糟一些,因为我引入了一个两个线程都可以访问的共享字段。从下面的屏幕截图中可以看出,在不同的代码运行中我们得到了不同的值。这是相当糟糕的消息;想象一下这是你的银行账户。我们可以使用“同步”来解决这些问题,正如我们将在本系列的未来文章中看到的那样。
此屏幕截图显示了第一次运行的结果,我们得到了这些最终结果:
此屏幕截图显示了另一次运行的结果,我们得到了不同的最终结果。噢,坏消息。
所以这是一个陷阱;永远不要假设线程和共享数据会很好地配合,因为它们不会。
循环
考虑以下问题。“系统必须向每个下订单的用户发送发票。此过程应在后台运行,并且不应对用户界面产生任何不利影响。”
考虑以下代码示例(附带的演示 ThreadTrap3 项目)。
不要运行此代码,它只是为了展示一个糟糕的例子。
using System;
using System.Collections.Generic;
using System.Threading;
namespace ThreadTrap3
{
/// <summary>
/// This code is bad as it starts a new thread for each invoice that it
/// has to send to a Customer. This could be 1000nds of threads, that will
/// all incur some overhead when the CPU has to context switch between the
/// threads.For this example it probably will not occur as the threads work is
/// so small, but for longer running operations there could be issues.
/// </summary>
class Program
{
static void Main(string[] args)
{
List<Customer> custs = new List<Customer>();
custs.Add(new Customer { CustomerEmail = "fred@gmail.com",
InvoiceNo = 1, Name = "fred" });
custs.Add(new Customer { CustomerEmail = "same@gmail.com",
InvoiceNo = 2, Name = "sam" });
custs.Add(new Customer { CustomerEmail = "john@gmail.com",
InvoiceNo = 3, Name = "john" });
custs.Add(new Customer { CustomerEmail = "ted@gmail.com",
InvoiceNo = 4, Name = "ted" });
InvoiceThread.CreateAllInvoices(custs);
Console.ReadLine();
}
}
public class InvoiceThread
{
private static Customer currentCustomer;
public static void CreateAllInvoices(List<Customer> customers)
{
//Create a new thread for every Invoice we need to send. Bad news
foreach (Customer cust in customers)
{
currentCustomer=cust;
Thread thread = new Thread(new ThreadStart(SendCustomerInvoice));
thread.Start();
}
}
private static void SendCustomerInvoice()
{
//Simulate sending an invoice
Console.WriteLine("Send invoice {0}, to Customer {1}",
currentCustomer.InvoiceNo.ToString(),
currentCustomer.Name);
}
}
/// <summary>
/// Simple data class
/// </summary>
public class Customer
{
public string Name { get; set; }
public string CustomerEmail { get; set; }
public int InvoiceNo { get; set; }
}
}
这个例子很糟糕,因为它为每个需要发送发票的客户创建了一个新线程。
foreach (Customer cust in customers)
{
currentCustomer=cust;
Thread thread = new Thread(new ThreadStart(SendCustomerInvoice));
thread.Start();
}
但这究竟为什么这么糟糕呢?它似乎有点道理;毕竟使用电子邮件发送发票可能需要相当长的时间。那么为什么不使用线程呢?当我在循环中创建新线程时,每个线程都需要分配一些 CPU 时间,因此 CPU 将花费大量时间进行上下文切换(上下文切换包括将 CPU 的上下文信息(寄存器)存储到当前线程的内核堆栈,并将上下文信息从所选执行线程的内核堆栈加载到 CPU),以允许每个线程一些 CPU 时间,结果执行的实际线程指令很少,系统甚至可能锁定。
这是一方面;另一方面,创建线程本身也有很大的开销。这就是为什么存在 ThreadPool
类。我们将在以后的文章中看到它。
更合理的方法是使用单个后台线程来发送**所有**发票,或者使用线程池,当一个线程完成时,它可以返回到共享池中。我们将在本系列的后续文章中探讨线程池。
锁持有时间过长
我们还没有涉及锁(第 3 部分将讨论这些),所以我不想花太多时间在这上面,但我会简要提及它们。
我们可以想象两个或多个线程共享一些公共数据,我们需要确保这些数据是安全的。现在在 .NET 中,有各种方法可以做到这一点,其中一种是使用“lock
”关键字(我们将在第 3 部分介绍),它确保对“lock”部分内的代码进行互斥访问。一个可能的问题是,程序员锁定整个方法来尝试确保共享数据安全,但实际上他们只需要锁定处理共享数据的几行代码。我认为我曾经听过一个很好的描述,称之为“锁粒度”,我觉得这总结得很好。基本上,只锁定您真正需要锁定的东西。
我们完成了
好了,这就是我这次想说的全部。多线程是一个复杂的主题,因此本系列会相当困难,但我认为值得一读。
下次
下次我们将探讨同步。
我能问一下,如果您喜欢这篇文章,请为它投票,因为这将告诉我我即将开始的这场多线程“圣战”是否值得创作文章。
非常感谢。
参考文献
- C# 中的多线程,Joseph Albahari
- 如何在 .NET 中停止线程(以及为什么 Thread.Abort 是邪恶的)
- System.Threading MSDN 页面
- Visual Basic .NET 多线程,Wrox